1use std::fs;
2use std::os::unix::fs::PermissionsExt;
3use std::path::{Path, PathBuf};
4use std::process::ExitCode;
5
6use crate::cli::ShimCommand;
7use crate::error::SboxError;
8
9const SHIM_TARGETS: &[&str] = &[
11 "npm", "pnpm", "yarn", "bun", "uv", "pip", "pip3", "poetry", "cargo", "composer",
12];
13
14pub fn execute(command: &ShimCommand) -> Result<ExitCode, SboxError> {
15 let shim_dir = resolve_shim_dir(command)?;
16
17 if !command.dry_run {
18 fs::create_dir_all(&shim_dir).map_err(|source| SboxError::InitWrite {
19 path: shim_dir.clone(),
20 source,
21 })?;
22 }
23
24 let mut created = 0usize;
25 let mut skipped = 0usize;
26
27 for &name in SHIM_TARGETS {
28 let dest = shim_dir.join(name);
29
30 if dest.exists() && !command.force && !command.dry_run {
31 println!("skip {} (already exists; use --force to overwrite)", dest.display());
32 skipped += 1;
33 continue;
34 }
35
36 let real_binary = find_real_binary(name, &shim_dir);
37 let script = render_shim(name, real_binary.as_deref());
38
39 if command.dry_run {
40 match &real_binary {
41 Some(p) => println!("would create {} -> {}", dest.display(), p.display()),
42 None => println!("would create {} (real binary not found)", dest.display()),
43 }
44 created += 1;
45 continue;
46 }
47
48 fs::write(&dest, &script).map_err(|source| SboxError::InitWrite {
49 path: dest.clone(),
50 source,
51 })?;
52
53 let mut perms = fs::metadata(&dest)
54 .map_err(|source| SboxError::InitWrite { path: dest.clone(), source })?
55 .permissions();
56 perms.set_mode(0o755);
57 fs::set_permissions(&dest, perms).map_err(|source| SboxError::InitWrite {
58 path: dest.clone(),
59 source,
60 })?;
61
62 match &real_binary {
63 Some(p) => println!("created {} -> {}", dest.display(), p.display()),
64 None => println!("created {} (real binary not found at shim time)", dest.display()),
65 }
66 created += 1;
67 }
68
69 if !command.dry_run {
70 println!();
71 if created > 0 {
72 println!("Add {} to your PATH before the real package manager binaries:", shim_dir.display());
73 println!();
74 println!(" export PATH=\"{}:$PATH\"", shim_dir.display());
75 println!();
76 println!("Then restart your shell or run: source ~/.bashrc");
77 }
78 if skipped > 0 {
79 println!("({skipped} skipped — use --force to overwrite)");
80 }
81 }
82
83 Ok(ExitCode::SUCCESS)
84}
85
86fn resolve_shim_dir(command: &ShimCommand) -> Result<PathBuf, SboxError> {
87 if let Some(dir) = &command.dir {
88 let abs = if dir.is_absolute() {
89 dir.clone()
90 } else {
91 std::env::current_dir()
92 .map_err(|source| SboxError::CurrentDirectory { source })?
93 .join(dir)
94 };
95 return Ok(abs);
96 }
97
98 if let Some(home) = std::env::var_os("HOME") {
100 return Ok(PathBuf::from(home).join(".local/bin"));
101 }
102
103 std::env::current_dir()
105 .map_err(|source| SboxError::CurrentDirectory { source })
106}
107
108fn find_real_binary(name: &str, exclude_dir: &Path) -> Option<PathBuf> {
110 let path_os = std::env::var_os("PATH")?;
111 for dir in std::env::split_paths(&path_os) {
112 if dir == exclude_dir {
113 continue;
114 }
115 let candidate = dir.join(name);
116 if is_executable_file(&candidate) {
117 return Some(candidate);
118 }
119 }
120 None
121}
122
123fn is_executable_file(path: &Path) -> bool {
124 path.metadata()
125 .map(|m| m.is_file() && (m.permissions().mode() & 0o111) != 0)
126 .unwrap_or(false)
127}
128
129fn render_shim(name: &str, real_binary: Option<&Path>) -> String {
135 let fallback = match real_binary {
136 Some(path) => format!("exec {} \"$@\"", path.display()),
137 None => format!(
138 "printf 'sbox shim: {name}: real binary not found; reinstall or run `sbox shim` again\\n' >&2\nexit 127"
139 ),
140 };
141
142 format!(
145 "#!/bin/sh\n\
146 # sbox shim: {name}\n\
147 # Generated by `sbox shim`. Re-run `sbox shim --dir DIR` to regenerate.\n\
148 _sbox_d=\"$PWD\"\n\
149 while true; do\n\
150 \x20 if [ -f \"$_sbox_d/sbox.yaml\" ]; then\n\
151 \x20 exec sbox run -- {name} \"$@\"\n\
152 \x20 fi\n\
153 \x20 [ \"$_sbox_d\" = \"/\" ] && break\n\
154 \x20 _sbox_d=\"${{_sbox_d%/*}}\"\n\
155 \x20 [ -z \"$_sbox_d\" ] && _sbox_d=\"/\"\n\
156 done\n\
157 {fallback}\n"
158 )
159}
160
161#[cfg(test)]
162mod tests {
163 use super::render_shim;
164
165 #[test]
166 fn shim_contains_sbox_run_delegation() {
167 let script = render_shim("npm", Some(std::path::Path::new("/usr/bin/npm")));
168 assert!(script.contains("exec sbox run -- npm \"$@\""));
169 assert!(script.contains("sbox.yaml"));
170 assert!(script.contains("exec /usr/bin/npm \"$@\""));
171 }
172
173 #[test]
174 fn shim_fallback_when_real_binary_missing() {
175 let script = render_shim("npm", None);
176 assert!(script.contains("real binary not found"));
177 assert!(script.contains("exit 127"));
178 }
179
180 #[test]
181 fn shim_walks_to_root() {
182 let script = render_shim("uv", Some(std::path::Path::new("/usr/local/bin/uv")));
183 assert!(script.contains("_sbox_d%/*"));
185 assert!(script.contains("[ \"$_sbox_d\" = \"/\" ] && break"));
187 }
188}