Skip to content

Commit a55a915

Browse files
add macOS global package crawling fallbacks and pyenv support (#34)
NPM crawler: add find_node_dirs_sync() helper and macOS fallback paths (Homebrew, nvm, volta, fnm) with deduplication in get_global_node_modules_paths(). Python crawler: add pyenv site-packages discovery via PYENV_ROOT. E2E tests: add macOS auto-discovery smoke tests for npm and pypi. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2e8e014 commit a55a915

File tree

4 files changed

+237
-4
lines changed

4 files changed

+237
-4
lines changed

crates/socket-patch-cli/tests/e2e_npm.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -483,6 +483,38 @@ fn test_npm_apply_force() {
483483
);
484484
}
485485

486+
/// macOS auto-discovery: `scan -g --json` without `--global-prefix` uses real path probing.
487+
#[cfg(target_os = "macos")]
488+
#[test]
489+
#[ignore]
490+
fn test_npm_macos_global_auto_discovery() {
491+
if !has_command("npm") {
492+
eprintln!("SKIP: npm not found on PATH");
493+
return;
494+
}
495+
496+
let cwd_dir = tempfile::tempdir().unwrap();
497+
let cwd = cwd_dir.path();
498+
499+
// Run scan -g without --global-prefix to exercise macOS auto-discovery
500+
let (code, stdout, stderr) = run(cwd, &["scan", "-g", "--json"]);
501+
502+
// Should complete without error (exit 0)
503+
assert_eq!(
504+
code, 0,
505+
"scan -g --json failed (exit {code}).\nstdout:\n{stdout}\nstderr:\n{stderr}"
506+
);
507+
508+
// Output should be valid JSON with scannedPackages field
509+
let scan: serde_json::Value = serde_json::from_str(&stdout)
510+
.unwrap_or_else(|e| panic!("invalid JSON from scan -g: {e}\nstdout:\n{stdout}"));
511+
assert!(
512+
scan["scannedPackages"].is_u64(),
513+
"scannedPackages should be a number, got: {}",
514+
scan["scannedPackages"]
515+
);
516+
}
517+
486518
/// UUID shortcut: `socket-patch <UUID>` should behave like `socket-patch get <UUID>`.
487519
#[test]
488520
#[ignore]

crates/socket-patch-cli/tests/e2e_pypi.rs

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -571,6 +571,38 @@ fn test_pypi_save_only() {
571571
);
572572
}
573573

574+
/// macOS auto-discovery: `scan -g --json` without `--global-prefix` uses real path probing.
575+
#[cfg(target_os = "macos")]
576+
#[test]
577+
#[ignore]
578+
fn test_pypi_macos_global_auto_discovery() {
579+
if !has_python3() {
580+
eprintln!("SKIP: python3 not found on PATH");
581+
return;
582+
}
583+
584+
let cwd_dir = tempfile::tempdir().unwrap();
585+
let cwd = cwd_dir.path();
586+
587+
// Run scan -g without --global-prefix to exercise macOS auto-discovery
588+
let (code, stdout, stderr) = run(cwd, &["scan", "-g", "--json"]);
589+
590+
// Should complete without error (exit 0)
591+
assert_eq!(
592+
code, 0,
593+
"scan -g --json failed (exit {code}).\nstdout:\n{stdout}\nstderr:\n{stderr}"
594+
);
595+
596+
// Output should be valid JSON with scannedPackages field
597+
let scan: serde_json::Value = serde_json::from_str(&stdout)
598+
.unwrap_or_else(|e| panic!("invalid JSON from scan -g: {e}\nstdout:\n{stdout}"));
599+
assert!(
600+
scan["scannedPackages"].is_u64(),
601+
"scannedPackages should be a number, got: {}",
602+
scan["scannedPackages"]
603+
);
604+
}
605+
574606
/// UUID shortcut: `socket-patch <UUID>` should behave like `socket-patch get <UUID>`.
575607
#[test]
576608
#[ignore]

crates/socket-patch-core/src/crawlers/npm_crawler.rs

Lines changed: 126 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,45 @@ pub fn get_bun_global_prefix() -> Option<String> {
173173
)
174174
}
175175

176+
// ---------------------------------------------------------------------------
177+
// Helpers: synchronous wildcard directory resolver
178+
// ---------------------------------------------------------------------------
179+
180+
/// Resolve a path with `"*"` wildcard segments synchronously.
181+
///
182+
/// Each segment is either a literal directory name or `"*"` which matches any
183+
/// directory entry. Symlinks are followed via `std::fs::metadata`.
184+
fn find_node_dirs_sync(base: &Path, segments: &[&str]) -> Vec<PathBuf> {
185+
if !base.is_dir() {
186+
return Vec::new();
187+
}
188+
if segments.is_empty() {
189+
return vec![base.to_path_buf()];
190+
}
191+
192+
let first = segments[0];
193+
let rest = &segments[1..];
194+
195+
if first == "*" {
196+
let mut results = Vec::new();
197+
if let Ok(entries) = std::fs::read_dir(base) {
198+
for entry in entries.flatten() {
199+
// Follow symlinks: use metadata() not symlink_metadata()
200+
let is_dir = entry
201+
.metadata()
202+
.map(|m| m.is_dir())
203+
.unwrap_or(false);
204+
if is_dir {
205+
results.extend(find_node_dirs_sync(&base.join(entry.file_name()), rest));
206+
}
207+
}
208+
}
209+
results
210+
} else {
211+
find_node_dirs_sync(&base.join(first), rest)
212+
}
213+
}
214+
176215
// ---------------------------------------------------------------------------
177216
// NpmCrawler
178217
// ---------------------------------------------------------------------------
@@ -297,19 +336,60 @@ impl NpmCrawler {
297336

298337
/// Collect global `node_modules` paths from all known package managers.
299338
fn get_global_node_modules_paths(&self) -> Vec<PathBuf> {
339+
let mut seen = HashSet::new();
300340
let mut paths = Vec::new();
301341

342+
let mut add = |p: PathBuf| {
343+
if p.is_dir() && seen.insert(p.clone()) {
344+
paths.push(p);
345+
}
346+
};
347+
302348
if let Ok(npm_path) = get_npm_global_prefix() {
303-
paths.push(PathBuf::from(npm_path));
349+
add(PathBuf::from(npm_path));
304350
}
305351
if let Some(pnpm_path) = get_pnpm_global_prefix() {
306-
paths.push(PathBuf::from(pnpm_path));
352+
add(PathBuf::from(pnpm_path));
307353
}
308354
if let Some(yarn_path) = get_yarn_global_prefix() {
309-
paths.push(PathBuf::from(yarn_path));
355+
add(PathBuf::from(yarn_path));
310356
}
311357
if let Some(bun_path) = get_bun_global_prefix() {
312-
paths.push(PathBuf::from(bun_path));
358+
add(PathBuf::from(bun_path));
359+
}
360+
361+
// macOS-specific fallback paths
362+
if cfg!(target_os = "macos") {
363+
let home = std::env::var("HOME").unwrap_or_default();
364+
365+
// Homebrew Apple Silicon
366+
add(PathBuf::from("/opt/homebrew/lib/node_modules"));
367+
// Homebrew Intel / default npm
368+
add(PathBuf::from("/usr/local/lib/node_modules"));
369+
370+
if !home.is_empty() {
371+
// nvm
372+
for p in find_node_dirs_sync(
373+
&PathBuf::from(&home).join(".nvm/versions/node"),
374+
&["*", "lib", "node_modules"],
375+
) {
376+
add(p);
377+
}
378+
// volta
379+
for p in find_node_dirs_sync(
380+
&PathBuf::from(&home).join(".volta/tools/image/node"),
381+
&["*", "lib", "node_modules"],
382+
) {
383+
add(p);
384+
}
385+
// fnm
386+
for p in find_node_dirs_sync(
387+
&PathBuf::from(&home).join(".fnm/node-versions"),
388+
&["*", "installation", "lib", "node_modules"],
389+
) {
390+
add(p);
391+
}
392+
}
313393
}
314394

315395
paths
@@ -790,6 +870,48 @@ mod tests {
790870
assert_eq!(packages[0].purl, "pkg:npm/@types/node@20.0.0");
791871
}
792872

873+
#[test]
874+
fn test_find_node_dirs_sync_wildcard() {
875+
// Create an nvm-like layout: base/v18.0.0/lib/node_modules
876+
let dir = tempfile::tempdir().unwrap();
877+
let nm1 = dir.path().join("v18.0.0/lib/node_modules");
878+
let nm2 = dir.path().join("v20.1.0/lib/node_modules");
879+
std::fs::create_dir_all(&nm1).unwrap();
880+
std::fs::create_dir_all(&nm2).unwrap();
881+
882+
let results = find_node_dirs_sync(dir.path(), &["*", "lib", "node_modules"]);
883+
assert_eq!(results.len(), 2);
884+
assert!(results.contains(&nm1));
885+
assert!(results.contains(&nm2));
886+
}
887+
888+
#[test]
889+
fn test_find_node_dirs_sync_empty() {
890+
// Non-existent base path should return empty
891+
let results = find_node_dirs_sync(Path::new("/nonexistent/path/xyz"), &["*", "lib"]);
892+
assert!(results.is_empty());
893+
}
894+
895+
#[test]
896+
fn test_find_node_dirs_sync_literal() {
897+
// All literal segments (no wildcard)
898+
let dir = tempfile::tempdir().unwrap();
899+
let target = dir.path().join("lib/node_modules");
900+
std::fs::create_dir_all(&target).unwrap();
901+
902+
let results = find_node_dirs_sync(dir.path(), &["lib", "node_modules"]);
903+
assert_eq!(results.len(), 1);
904+
assert_eq!(results[0], target);
905+
}
906+
907+
#[cfg(target_os = "macos")]
908+
#[test]
909+
fn test_macos_get_global_node_modules_paths_no_panic() {
910+
let crawler = NpmCrawler::new();
911+
// Should not panic, even if no package managers are installed
912+
let _paths = crawler.get_global_node_modules_paths();
913+
}
914+
793915
#[tokio::test]
794916
async fn test_find_by_purls() {
795917
let dir = tempfile::tempdir().unwrap();

crates/socket-patch-core/src/crawlers/python_crawler.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,22 @@ pub async fn get_global_python_site_packages() -> Vec<PathBuf> {
382382
}
383383
}
384384

385+
// pyenv (works on macOS and Linux)
386+
if !cfg!(windows) {
387+
let pyenv_root = std::env::var("PYENV_ROOT")
388+
.map(PathBuf::from)
389+
.unwrap_or_else(|_| PathBuf::from(&home_dir).join(".pyenv"));
390+
let pyenv_versions = pyenv_root.join("versions");
391+
let pyenv_matches = find_python_dirs(
392+
&pyenv_versions,
393+
&["*", "lib", "python3.*", "site-packages"],
394+
)
395+
.await;
396+
for m in pyenv_matches {
397+
add_path(m, &mut seen, &mut results);
398+
}
399+
}
400+
385401
// Conda
386402
let anaconda = PathBuf::from(&home_dir).join("anaconda3");
387403
scan_well_known(&anaconda, "site-packages", &mut seen, &mut results).await;
@@ -736,6 +752,37 @@ mod tests {
736752
assert_eq!(results[0], sp1);
737753
}
738754

755+
#[tokio::test]
756+
async fn test_find_python_dirs_pyenv_layout() {
757+
// Create a pyenv-like layout: versions/3.11.5/lib/python3.11/site-packages
758+
let dir = tempfile::tempdir().unwrap();
759+
let sp1 = dir
760+
.path()
761+
.join("versions")
762+
.join("3.11.5")
763+
.join("lib")
764+
.join("python3.11")
765+
.join("site-packages");
766+
let sp2 = dir
767+
.path()
768+
.join("versions")
769+
.join("3.12.0")
770+
.join("lib")
771+
.join("python3.12")
772+
.join("site-packages");
773+
tokio::fs::create_dir_all(&sp1).await.unwrap();
774+
tokio::fs::create_dir_all(&sp2).await.unwrap();
775+
776+
let results = find_python_dirs(
777+
&dir.path().join("versions"),
778+
&["*", "lib", "python3.*", "site-packages"],
779+
)
780+
.await;
781+
assert_eq!(results.len(), 2);
782+
assert!(results.contains(&sp1));
783+
assert!(results.contains(&sp2));
784+
}
785+
739786
#[tokio::test]
740787
async fn test_crawl_all_python() {
741788
let dir = tempfile::tempdir().unwrap();

0 commit comments

Comments
 (0)