1use anyhow::{Context, Result, bail};
2use std::env;
3use std::fs;
4use std::io::{self, IsTerminal, Read};
5use std::path::{Path, PathBuf};
6use std::process::Command;
7
8pub use oxdock_core::{
9 Guard, Step, StepKind, parse_script, run_steps, run_steps_with_context, shell_program,
10};
11
12pub fn run() -> Result<()> {
13 let mut args = std::env::args().skip(1);
14 let opts = Options::parse(&mut args)?;
15 execute(opts)
16}
17
18#[derive(Debug, Clone)]
19pub enum ScriptSource {
20 Path(PathBuf),
21 Stdin,
22}
23
24#[derive(Debug, Clone)]
25pub struct Options {
26 pub script: ScriptSource,
27 pub shell: bool,
28}
29
30impl Options {
31 pub fn parse(args: &mut impl Iterator<Item = String>) -> Result<Self> {
32 let mut script: Option<ScriptSource> = None;
33 let mut shell = false;
34 while let Some(arg) = args.next() {
35 if arg.is_empty() {
36 continue;
37 }
38 match arg.as_str() {
39 "--script" => {
40 let p = args
41 .next()
42 .ok_or_else(|| anyhow::anyhow!("--script requires a path"))?;
43 if p == "-" {
44 script = Some(ScriptSource::Stdin);
45 } else {
46 script = Some(ScriptSource::Path(PathBuf::from(p)));
47 }
48 }
49 "--shell" => {
50 shell = true;
51 }
52 other => bail!("unexpected flag: {}", other),
53 }
54 }
55
56 let script = script.unwrap_or(ScriptSource::Stdin);
57
58 Ok(Self { script, shell })
59 }
60}
61
62pub fn execute(opts: Options) -> Result<()> {
63 #[cfg(windows)]
64 maybe_reexec_shell_to_temp(&opts)?;
65
66 let workspace_root = discover_workspace_root()?;
69
70 let temp = tempfile::tempdir().context("failed to create temp dir")?;
71 let temp_path = if opts.shell {
73 temp.keep()
74 } else {
75 temp.path().to_path_buf()
76 };
77
78 archive_head(&workspace_root, &temp_path)?;
80
81 let script = match &opts.script {
83 ScriptSource::Path(path) => fs::read_to_string(path)
84 .with_context(|| format!("failed to read script at {}", path.display()))?,
85 ScriptSource::Stdin => {
86 let stdin = io::stdin();
87 if stdin.is_terminal() {
88 if opts.shell {
92 String::new()
93 } else {
94 bail!(
95 "no stdin detected; pass --script <file> or pipe a script into stdin (use --script - if explicit)"
96 );
97 }
98 } else {
99 let mut buf = String::new();
100 stdin
101 .lock()
102 .read_to_string(&mut buf)
103 .context("failed to read script from stdin")?;
104 buf
105 }
106 }
107 };
108 if !script.trim().is_empty() {
111 let steps = parse_script(&script)?;
112 run_steps_with_context(&temp_path, &workspace_root, &steps)?;
115 }
116
117 if opts.shell {
119 if !has_controlling_tty() {
120 bail!("--shell requires a tty (no controlling tty available)");
121 }
122 return run_shell(&temp_path, &workspace_root);
123 }
124
125 Ok(())
126}
127
128fn has_controlling_tty() -> bool {
129 #[cfg(unix)]
130 {
131 std::fs::File::open("/dev/tty").is_ok()
132 }
133
134 #[cfg(windows)]
135 {
136 std::fs::File::open("CONIN$").is_ok()
137 }
138
139 #[cfg(not(any(unix, windows)))]
140 {
141 false
142 }
143}
144
145#[cfg(windows)]
146fn maybe_reexec_shell_to_temp(opts: &Options) -> Result<()> {
147 if !opts.shell {
150 return Ok(());
151 }
152 if std::env::var("OXDOCK_SHELL_REEXEC").ok().as_deref() == Some("1") {
153 return Ok(());
154 }
155
156 let self_path = std::env::current_exe().context("determine current executable")?;
157 let mut temp_path = std::env::temp_dir();
158 let ts = std::time::SystemTime::now()
159 .duration_since(std::time::UNIX_EPOCH)
160 .unwrap_or_default()
161 .as_millis();
162 temp_path.push(format!("oxdock-shell-{ts}-{}.exe", std::process::id()));
163
164 fs::copy(&self_path, &temp_path)
165 .with_context(|| format!("failed to copy shell runner to {}", temp_path.display()))?;
166
167 let mut cmd = Command::new(&temp_path);
168 cmd.args(std::env::args_os().skip(1));
169 cmd.env("OXDOCK_SHELL_REEXEC", "1");
170
171 cmd.spawn()
172 .with_context(|| format!("failed to spawn shell from {}", temp_path.display()))?;
173
174 std::process::exit(0);
176}
177
178fn discover_workspace_root() -> Result<PathBuf> {
179 if let Ok(root) = std::env::var("OXDOCK_WORKSPACE_ROOT") {
180 return Ok(PathBuf::from(root));
181 }
182
183 if let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR")
184 && let Some(parent) = PathBuf::from(manifest_dir).parent()
185 {
186 return Ok(parent.to_path_buf());
187 }
188
189 if let Ok(output) = Command::new("git")
191 .arg("rev-parse")
192 .arg("--show-toplevel")
193 .output()
194 && output.status.success()
195 {
196 let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
197 if !path.is_empty() {
198 return Ok(PathBuf::from(path));
199 }
200 }
201
202 std::env::current_dir().context("failed to determine current directory for workspace root")
203}
204pub fn run_script(workspace_root: &Path, steps: &[Step]) -> Result<()> {
205 run_steps_with_context(workspace_root, workspace_root, steps)
206}
207
208fn shell_banner(cwd: &Path, workspace_root: &Path) -> String {
209 let pkg = env::var("CARGO_PKG_NAME").unwrap_or_else(|_| "oxdock".to_string());
210 format!(
211 "{} shell workspace: {} (materialized from git HEAD at {})",
212 pkg,
213 cwd.display(),
214 workspace_root.display()
215 )
216}
217
218#[cfg(windows)]
219fn escape_for_cmd(s: &str) -> String {
220 s.replace('^', "^^")
222 .replace('&', "^&")
223 .replace('|', "^|")
224 .replace('>', "^>")
225 .replace('<', "^<")
226}
227
228fn run_shell(cwd: &Path, workspace_root: &Path) -> Result<()> {
229 let banner = shell_banner(cwd, workspace_root);
230
231 #[cfg(unix)]
232 {
233 let mut cmd = Command::new(shell_program());
234 cmd.current_dir(cwd);
235
236 let script = format!("printf '%s\\n' \"{}\"; exec {}", banner, shell_program());
238 cmd.arg("-c").arg(script);
239
240 if let Ok(tty) = fs::File::open("/dev/tty") {
242 cmd.stdin(tty);
243 }
244
245 let status = cmd.status()?;
246 if !status.success() {
247 bail!("shell exited with status {}", status);
248 }
249 Ok(())
250 }
251
252 #[cfg(windows)]
253 {
254 let mut cmd = Command::new("cmd");
258 cmd.arg("/C")
259 .arg("start")
260 .arg("oxdock shell")
261 .arg("/D")
262 .arg(cwd)
263 .arg("cmd")
264 .arg("/K")
265 .arg(format!("echo {} && cd /d .", escape_for_cmd(&banner)));
266
267 cmd.spawn()
270 .context("failed to start interactive shell window")?;
271 Ok(())
272 }
273
274 #[cfg(not(any(unix, windows)))]
275 {
276 let _ = cwd;
277 bail!("interactive shell unsupported on this platform");
278 }
279}
280fn run_cmd(cmd: &mut Command) -> Result<()> {
281 let status = cmd
282 .status()
283 .with_context(|| format!("failed to run {:?}", cmd))?;
284 if !status.success() {
285 bail!("command {:?} failed with status {}", cmd, status);
286 }
287 Ok(())
288}
289
290fn archive_head(workspace_root: &Path, temp_root: &Path) -> Result<()> {
291 let archive_path = temp_root.join("src.tar");
292 let archive_str = archive_path.to_string_lossy().to_string();
293 run_cmd(Command::new("git").current_dir(workspace_root).args([
294 "archive",
295 "--format=tar",
296 "--output",
297 &archive_str,
298 "HEAD",
299 ]))?;
300
301 run_cmd(
302 Command::new("tar")
303 .arg("-xf")
304 .arg(&archive_str)
305 .arg("-C")
306 .arg(temp_root),
307 )?;
308
309 fs::remove_file(&archive_path)
311 .with_context(|| format!("failed to remove {}", archive_path.display()))?;
312 Ok(())
313}