1use anyhow::{Context, Result, bail};
2use oxdock_fs::{GuardedPath, GuardedTempDir, PathResolver, discover_workspace_root};
3#[cfg(test)]
4use oxdock_process::CommandSnapshot;
5use oxdock_process::{CommandBuilder, 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 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
78pub struct ExecutionResult {
79 pub tempdir: GuardedTempDir,
80 pub final_cwd: GuardedPath,
81}
82
83pub fn execute_with_result(opts: Options, workspace_root: GuardedPath) -> Result<ExecutionResult> {
84 if opts.shell {
85 bail!("execute_with_result does not support --shell");
86 }
87
88 let tempdir = GuardedPath::tempdir().context("failed to create temp dir")?;
89 let temp_root = tempdir.as_guarded_path().clone();
90
91 let script = match &opts.script {
92 ScriptSource::Path(path) => {
93 let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())?;
94 resolver
95 .read_to_string(path)
96 .with_context(|| format!("failed to read script at {}", path.display()))?
97 }
98 ScriptSource::Stdin => {
99 let mut buf = String::new();
100 io::stdin()
101 .lock()
102 .read_to_string(&mut buf)
103 .context("failed to read script from stdin")?;
104 buf
105 }
106 };
107
108 let mut final_cwd = temp_root.clone();
109 if !script.trim().is_empty() {
110 let steps = parse_script(&script)?;
111 final_cwd = run_steps_with_context_result_with_io(
112 &temp_root,
113 &workspace_root,
114 &steps,
115 ExecIo::new(),
116 )?;
117 }
118
119 Ok(ExecutionResult { tempdir, final_cwd })
120}
121
122fn execute_with_shell_runner<F>(
123 opts: Options,
124 workspace_root: GuardedPath,
125 shell_runner: F,
126 require_tty: bool,
127) -> Result<()>
128where
129 F: FnOnce(&GuardedPath, &GuardedPath) -> Result<()>,
130{
131 #[cfg(windows)]
132 maybe_reexec_shell_to_temp(&opts)?;
133
134 let tempdir = GuardedPath::tempdir().context("failed to create temp dir")?;
135 let temp_root = tempdir.as_guarded_path().clone();
136
137 let script = match &opts.script {
139 ScriptSource::Path(path) => {
140 let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())?;
143 resolver
144 .read_to_string(path)
145 .with_context(|| format!("failed to read script at {}", path.display()))?
146 }
147 ScriptSource::Stdin => {
148 let stdin = io::stdin();
149 if stdin.is_terminal() {
150 if opts.shell {
155 String::new()
156 } else {
157 bail!(
158 "no stdin detected; pass --script <file> or pipe a script into stdin (use --script - if explicit)"
159 );
160 }
161 } else {
162 let mut buf = String::new();
163 stdin
164 .lock()
165 .read_to_string(&mut buf)
166 .context("failed to read script from stdin")?;
167 buf
168 }
169 }
170 };
171
172 let mut final_cwd = temp_root.clone();
175 if !script.trim().is_empty() {
176 let steps = parse_script(&script)?;
177 let mut stdin_handle: Option<SharedInput> = None;
186 if let ScriptSource::Path(_) = opts.script {
187 let stdin = io::stdin();
188 if !stdin.is_terminal() {
189 stdin_handle = Some(Arc::new(Mutex::new(stdin)));
195 }
196 }
197
198 let mut io_cfg = ExecIo::new();
199 io_cfg.set_stdin(stdin_handle);
200 final_cwd =
201 run_steps_with_context_result_with_io(&temp_root, &workspace_root, &steps, io_cfg)?;
202 }
203
204 if opts.shell {
206 if require_tty && !has_controlling_tty() {
207 bail!("--shell requires a tty (no controlling tty available)");
208 }
209 return shell_runner(&final_cwd, &workspace_root);
210 }
211
212 Ok(())
213}
214
215#[cfg(test)]
216fn execute_for_test<F>(opts: Options, workspace_root: GuardedPath, shell_runner: F) -> Result<()>
217where
218 F: FnOnce(&GuardedPath, &GuardedPath) -> Result<()>,
219{
220 execute_with_shell_runner(opts, workspace_root, shell_runner, false)
221}
222
223fn has_controlling_tty() -> bool {
224 #[cfg(unix)]
228 {
229 io::stdin().is_terminal() || io::stderr().is_terminal()
230 }
231
232 #[cfg(windows)]
233 {
234 io::stdin().is_terminal() || io::stderr().is_terminal()
235 }
236
237 #[cfg(not(any(unix, windows)))]
238 {
239 false
240 }
241}
242
243#[cfg(windows)]
244fn maybe_reexec_shell_to_temp(opts: &Options) -> Result<()> {
245 if !opts.shell {
248 return Ok(());
249 }
250 if std::env::var("OXDOCK_SHELL_REEXEC").ok().as_deref() == Some("1") {
251 return Ok(());
252 }
253
254 let self_path = std::env::current_exe().context("determine current executable")?;
255 let base_temp =
256 GuardedPath::new_root(std::env::temp_dir().as_path()).context("guard system temp dir")?;
257 let ts = std::time::SystemTime::now()
258 .duration_since(std::time::UNIX_EPOCH)
259 .unwrap_or_default()
260 .as_millis();
261 let temp_file = base_temp
262 .join(&format!("oxdock-shell-{ts}-{}.exe", std::process::id()))
263 .context("construct temp shell path")?;
264
265 let temp_root_guard = temp_file
269 .parent()
270 .ok_or_else(|| anyhow::anyhow!("temp path unexpectedly missing parent"))?;
271 let resolver_temp = PathResolver::new(temp_root_guard.as_path(), temp_root_guard.as_path())?;
272 let dest = temp_file;
273 #[allow(clippy::disallowed_types)]
274 let source = oxdock_fs::UnguardedPath::new(self_path);
275 resolver_temp
276 .copy_file_from_unguarded(&source, &dest)
277 .with_context(|| format!("failed to copy shell runner to {}", dest.display()))?;
278
279 let mut cmd = CommandBuilder::new(dest.as_path());
280 cmd.args(std::env::args_os().skip(1));
281 cmd.env("OXDOCK_SHELL_REEXEC", "1");
282 cmd.spawn()
283 .with_context(|| format!("failed to spawn shell from {}", dest.display()))?;
284
285 std::process::exit(0);
287}
288
289pub fn run_script(workspace_root: &GuardedPath, steps: &[Step]) -> Result<()> {
290 run_steps_with_context(workspace_root, workspace_root, steps)
291}
292
293fn shell_banner(cwd: &GuardedPath, workspace_root: &GuardedPath) -> String {
294 #[cfg(windows)]
295 let cwd_disp = oxdock_fs::command_path(cwd).as_ref().display().to_string();
296 #[cfg(windows)]
297 let workspace_disp = oxdock_fs::command_path(workspace_root)
298 .as_ref()
299 .display()
300 .to_string();
301
302 #[cfg(not(windows))]
303 let cwd_disp = cwd.display().to_string();
304 #[cfg(not(windows))]
305 let workspace_disp = workspace_root.display().to_string();
306
307 let pkg = env::var("CARGO_PKG_NAME").unwrap_or_else(|_| "oxdock".to_string());
308 indoc::formatdoc! {"
309 {pkg} shell workspace
310 cwd: {cwd_disp}
311 source: workspace root at {workspace_disp}
312 lifetime: temporary directory created for this shell session; it disappears when you exit
313 creation: temp workspace starts empty unless your script copies files into it
314
315 WARNING: This shell still runs on your host filesystem and is **not** isolated!
316 "}
317}
318
319#[cfg(windows)]
320fn escape_for_cmd(s: &str) -> String {
321 s.replace('^', "^^")
323 .replace('&', "^&")
324 .replace('|', "^|")
325 .replace('>', "^>")
326 .replace('<', "^<")
327}
328
329#[cfg(windows)]
330fn windows_banner_command(banner: &str, cwd: &GuardedPath) -> String {
331 let mut parts: Vec<String> = banner
332 .lines()
333 .map(|line| format!("echo {}", escape_for_cmd(line)))
334 .collect();
335 let cwd_path = oxdock_fs::command_path(cwd);
336 parts.push(format!(
337 "cd /d {}",
338 escape_for_cmd(&cwd_path.as_ref().display().to_string())
339 ));
340 parts.join(" && ")
341}
342
343fn run_shell(cwd: &GuardedPath, workspace_root: &GuardedPath) -> Result<()> {
345 let banner = shell_banner(cwd, workspace_root);
346
347 #[cfg(unix)]
348 {
349 let mut cmd = CommandBuilder::new(shell_program());
350 cmd.current_dir(cwd.as_path());
351
352 let script = format!("printf '%s\\n' \"{}\"; exec {}", banner, shell_program());
354 cmd.arg("-c").arg(script);
355
356 #[cfg(not(miri))]
359 {
360 #[allow(clippy::disallowed_types)]
361 let tty_path = oxdock_fs::UnguardedPath::new("/dev/tty");
362 if let Ok(resolver) =
363 PathResolver::new(workspace_root.as_path(), workspace_root.as_path())
364 && let Ok(tty) = resolver.open_file_unguarded(&tty_path)
365 {
366 cmd.stdin_file(tty);
367 }
368 }
369
370 if try_shell_command_hook(&mut cmd)? {
371 return Ok(());
372 }
373
374 let status = cmd.status()?;
375 if !status.success() {
376 bail!("shell exited with status {}", status);
377 }
378 Ok(())
379 }
380
381 #[cfg(windows)]
382 {
383 let cwd_path = oxdock_fs::command_path(cwd);
387 let banner_cmd = windows_banner_command(&banner, cwd);
388 let mut cmd = CommandBuilder::new("cmd");
389 cmd.current_dir(cwd_path.as_ref())
390 .arg("/C")
391 .arg("start")
392 .arg("oxdock shell")
393 .arg("cmd")
394 .arg("/K")
395 .arg(banner_cmd);
396
397 if try_shell_command_hook(&mut cmd)? {
398 return Ok(());
399 }
400
401 cmd.spawn()
404 .context("failed to start interactive shell window")?;
405 Ok(())
406 }
407
408 #[cfg(not(any(unix, windows)))]
409 {
410 let _ = cwd;
411 bail!("interactive shell unsupported on this platform");
412 }
413}
414#[cfg(test)]
415type ShellCmdHook = dyn FnMut(&CommandSnapshot) -> Result<()> + Send;
416
417#[cfg(test)]
418thread_local! {
419 static SHELL_CMD_HOOK: std::cell::RefCell<Option<Box<ShellCmdHook>>> = std::cell::RefCell::new(None);
420}
421
422#[cfg(test)]
423fn set_shell_command_hook<F>(hook: F)
424where
425 F: FnMut(&CommandSnapshot) -> Result<()> + Send + 'static,
426{
427 SHELL_CMD_HOOK.with(|slot| {
428 *slot.borrow_mut() = Some(Box::new(hook));
429 });
430}
431
432#[cfg(test)]
433fn clear_shell_command_hook() {
434 SHELL_CMD_HOOK.with(|slot| {
435 *slot.borrow_mut() = None;
436 });
437}
438
439#[cfg(test)]
440fn try_shell_command_hook(cmd: &mut CommandBuilder) -> Result<bool> {
441 SHELL_CMD_HOOK.with(|slot| {
442 if let Some(hook) = slot.borrow_mut().as_mut() {
443 let snap = cmd.snapshot();
444 hook(&snap)?;
445 return Ok(true);
446 }
447 Ok(false)
448 })
449}
450
451#[cfg(not(test))]
452fn try_shell_command_hook(_cmd: &mut CommandBuilder) -> Result<bool> {
453 Ok(false)
454}
455
456#[cfg(test)]
459mod tests {
460 use super::*;
461 use indoc::indoc;
462 use oxdock_fs::PathResolver;
463 use std::cell::{Cell, RefCell};
464
465 #[cfg_attr(
466 miri,
467 ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
468 )]
469 #[test]
470 fn shell_runner_receives_final_workdir() -> Result<()> {
471 let workspace = GuardedPath::tempdir()?;
472 let workspace_root = workspace.as_guarded_path().clone();
473 let script_path = workspace_root.join("script.ox")?;
474 let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())?;
475 let script = indoc! {"
476 WRITE temp.txt 123
477 WORKDIR sub
478 "};
479 resolver.write_file(&script_path, script.as_bytes())?;
480
481 let opts = Options {
482 script: ScriptSource::Path(script_path),
483 shell: true,
484 };
485
486 let observed = Cell::new(false);
487 execute_for_test(opts, workspace_root.clone(), |cwd, _| {
488 assert!(
489 cwd.as_path().ends_with("sub"),
490 "final cwd should end in WORKDIR target, got {}",
491 cwd.display()
492 );
493
494 let temp_root = GuardedPath::new_root(cwd.root())
495 .context("construct guard for temp workspace root")?;
496 let sub_dir = temp_root.join("sub")?;
497 assert_eq!(
498 cwd.as_path(),
499 sub_dir.as_path(),
500 "shell runner cwd should match guarded sub dir"
501 );
502 let temp_file = temp_root.join("temp.txt")?;
503 let temp_resolver = PathResolver::new(temp_root.as_path(), temp_root.as_path())?;
504 let contents = temp_resolver.read_to_string(&temp_file)?;
505 assert!(
506 contents.contains("123"),
507 "expected WRITE command to materialize temp file"
508 );
509 observed.set(true);
510 Ok(())
511 })?;
512
513 assert!(
514 observed.into_inner(),
515 "shell runner closure should have been invoked"
516 );
517 Ok(())
518 }
519
520 #[cfg(any(unix, windows))]
521 #[test]
522 fn run_shell_builds_command_for_platform() -> Result<()> {
523 let workspace = GuardedPath::tempdir()?;
524 let workspace_root = workspace.as_guarded_path().clone();
525 let cwd = workspace_root.join("subdir")?;
526 #[cfg(not(miri))]
527 {
528 let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())?;
529 resolver.create_dir_all(&cwd)?;
530 }
531
532 let captured = std::sync::Arc::new(Mutex::new(None::<CommandSnapshot>));
533 let guard = captured.clone();
534 set_shell_command_hook(move |cmd| {
535 *guard.lock().unwrap() = Some(cmd.clone());
536 Ok(())
537 });
538 run_shell(&cwd, &workspace_root)?;
539 clear_shell_command_hook();
540
541 let snap = captured
542 .lock()
543 .unwrap()
544 .clone()
545 .expect("hook should capture snapshot");
546 let cwd_path = snap.cwd.expect("cwd should be set");
547 assert!(
548 cwd_path.ends_with("subdir"),
549 "expected cwd to include subdir, got {}",
550 cwd_path.display()
551 );
552
553 #[cfg(unix)]
554 {
555 let program = snap.program.to_string_lossy();
556 assert_eq!(program, shell_program(), "expected shell program name");
557 let args: Vec<_> = snap
558 .args
559 .iter()
560 .map(|s| s.to_string_lossy().to_string())
561 .collect();
562 assert_eq!(
563 args.len(),
564 2,
565 "expected two args (-c script), got {:?}",
566 args
567 );
568 assert_eq!(args[0], "-c");
569 assert!(
570 args[1].contains("exec"),
571 "expected script to exec the shell, got {:?}",
572 args[1]
573 );
574 }
575
576 #[cfg(windows)]
577 {
578 let program = snap.program.to_string_lossy().to_string();
579 assert_eq!(program, "cmd", "expected cmd.exe launcher");
580 let args: Vec<_> = snap
581 .args
582 .iter()
583 .map(|s| s.to_string_lossy().to_string())
584 .collect();
585 let banner_cmd = windows_banner_command(&shell_banner(&cwd, &workspace_root), &cwd);
586 let expected = vec![
587 "/C".to_string(),
588 "start".to_string(),
589 "oxdock shell".to_string(),
590 "cmd".to_string(),
591 "/K".to_string(),
592 banner_cmd,
593 ];
594 assert_eq!(args, expected, "expected exact windows shell argv");
595 }
596
597 Ok(())
598 }
599
600 #[cfg_attr(
601 miri,
602 ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
603 )]
604 #[test]
605 fn options_parse_requires_script_path_value() {
606 let workspace = GuardedPath::tempdir().expect("tempdir");
607 let mut args = vec!["--script".to_string()].into_iter();
608 let err = Options::parse(&mut args, workspace.as_guarded_path())
609 .expect_err("expected missing path error");
610 assert!(err.to_string().contains("--script requires a path"));
611 }
612
613 #[cfg_attr(
614 miri,
615 ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
616 )]
617 #[test]
618 fn options_parse_script_path_and_shell() {
619 let workspace = GuardedPath::tempdir().expect("tempdir");
620 let workspace_root = workspace.as_guarded_path().clone();
621 let script_path = workspace_root.join("script.txt").expect("script path");
622 let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())
623 .expect("resolver");
624 resolver
625 .write_file(&script_path, b"WRITE out.txt hi")
626 .expect("write script");
627 let mut args = vec![
628 "--script".to_string(),
629 "script.txt".to_string(),
630 "--shell".to_string(),
631 ]
632 .into_iter();
633 let opts = Options::parse(&mut args, &workspace_root).expect("parse");
634 assert!(opts.shell);
635 match opts.script {
636 ScriptSource::Path(path) => assert_eq!(path, script_path),
637 ScriptSource::Stdin => panic!("expected path script"),
638 }
639 }
640
641 #[cfg_attr(
642 miri,
643 ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
644 )]
645 #[test]
646 fn execute_with_result_runs_script() {
647 let workspace = GuardedPath::tempdir().expect("tempdir");
648 let workspace_root = workspace.as_guarded_path().clone();
649 let script_path = workspace_root.join("script.txt").expect("script path");
650 let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())
651 .expect("resolver");
652 resolver
653 .write_file(&script_path, b"WRITE out.txt hi")
654 .expect("write script");
655 let opts = Options {
656 script: ScriptSource::Path(script_path),
657 shell: false,
658 };
659 let ExecutionResult { tempdir, final_cwd } =
660 execute_with_result(opts, workspace_root).expect("execute");
661 assert_eq!(tempdir.as_guarded_path(), &final_cwd);
662 let temp_resolver = PathResolver::new(
663 tempdir.as_guarded_path().root(),
664 tempdir.as_guarded_path().root(),
665 )
666 .expect("resolver");
667 let out = tempdir.as_guarded_path().join("out.txt").expect("out path");
668 let contents = temp_resolver.read_to_string(&out).expect("read out");
669 assert_eq!(contents.trim(), "hi");
670 }
671
672 #[cfg_attr(
673 miri,
674 ignore = "GuardedPath::tempdir relies on OS tempdirs; blocked under Miri isolation"
675 )]
676 #[test]
677 fn execute_for_test_invokes_shell_runner() -> Result<()> {
678 let workspace = GuardedPath::tempdir()?;
679 let workspace_root = workspace.as_guarded_path().clone();
680 let script_path = workspace_root.join("empty.txt")?;
681 let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())?;
682 resolver.write_file(&script_path, b"")?;
683 let opts = Options {
684 script: ScriptSource::Path(script_path),
685 shell: true,
686 };
687 let called = RefCell::new(None::<(String, String)>);
688 execute_for_test(opts, workspace_root.clone(), |cwd, workspace| {
689 called.replace(Some((cwd.display(), workspace.display())));
690 Ok(())
691 })?;
692 let seen = called.borrow().clone().expect("shell runner called");
693 assert_eq!(seen.1, workspace_root.display());
694 Ok(())
695 }
696}
697
698#[cfg(all(test, windows))]
699mod windows_shell_tests {
700 use super::*;
701 use oxdock_fs::PathResolver;
702
703 #[test]
704 fn command_path_strips_verbatim_prefix() -> Result<()> {
705 let temp = GuardedPath::tempdir()?;
706 let converted = oxdock_fs::command_path(temp.as_guarded_path());
707 let as_str = converted.as_ref().display().to_string();
708 assert!(
709 !as_str.starts_with(r"\\?\"),
710 "expected non-verbatim path, got {as_str}"
711 );
712 Ok(())
713 }
714
715 #[test]
716 fn windows_banner_command_emits_all_lines() {
717 let banner = "line1\nline2\nline3";
718 let workspace = GuardedPath::tempdir().expect("tempdir");
719 let cwd = workspace.as_guarded_path().clone();
720 let cmd = windows_banner_command(banner, &cwd);
721 assert!(cmd.contains("line1"));
722 assert!(cmd.contains("line2"));
723 assert!(cmd.contains("line3"));
724 assert!(cmd.contains("cd /d "));
725 }
726
727 #[test]
728 fn run_shell_builds_windows_command() -> Result<()> {
729 let workspace = GuardedPath::tempdir_with(|builder| {
730 builder.prefix("oxdock shell win ");
731 })?;
732 let workspace_root = workspace.as_guarded_path().clone();
733 let cwd = workspace_root.join("subdir")?;
734 let resolver = PathResolver::new(workspace_root.as_path(), workspace_root.as_path())?;
735 resolver.create_dir_all(&cwd)?;
736
737 let captured = std::sync::Arc::new(Mutex::new(None::<CommandSnapshot>));
738 let guard = captured.clone();
739 set_shell_command_hook(move |cmd| {
740 *guard.lock().unwrap() = Some(cmd.clone());
741 Ok(())
742 });
743 run_shell(&cwd, &workspace_root)?;
744 clear_shell_command_hook();
745
746 let snap = captured
747 .lock()
748 .unwrap()
749 .clone()
750 .expect("hook should capture snapshot");
751 let program = snap.program.to_string_lossy().to_string();
752 assert_eq!(program, "cmd", "expected cmd.exe launcher");
753 let args: Vec<_> = snap
754 .args
755 .iter()
756 .map(|s| s.to_string_lossy().to_string())
757 .collect();
758 let banner_cmd = windows_banner_command(&shell_banner(&cwd, &workspace_root), &cwd);
759 let expected = vec![
760 "/C".to_string(),
761 "start".to_string(),
762 "oxdock shell".to_string(),
763 "cmd".to_string(),
764 "/K".to_string(),
765 banner_cmd,
766 ];
767 assert_eq!(args, expected, "expected exact windows shell argv");
768 let cwd_path = snap.cwd.expect("cwd should be set");
769 assert!(
770 cwd_path.ends_with("subdir"),
771 "expected cwd to include subdir, got {}",
772 cwd_path.display()
773 );
774 Ok(())
775 }
776}