Skip to content

Commit 34f13e0

Browse files
Fix yarn 1.0.x global packages being missed (#437) (#1798)
* Start fix for #437 Assisted-by: Claude Code:claude-opus-5-5 * Find yarn 1.0.x global packages Yarn 1.0.0 to 1.0.2 has no `yarn global dir` command, so global scans (`scan -g`, `get -g`, `rollback -g`, `vex -g`) found none of the packages installed with `yarn global add` and still reported success. When `yarn global dir` gives no answer and `yarn --version` reports 1.0.x, the global folder yarn 1.0.x itself uses is crawled instead: ~/.config/yarn/global (/usr/local/share/.config/yarn/global for root), or %LOCALAPPDATA%\Yarn\config\global on Windows. Newer yarn releases and machines without yarn behave as before. Fixes #437 Assisted-by: Claude Code:claude-opus-5-5 * Prefer a configured yarn 1.0.x global folder Yarn 1.0.x also reads `--global-folder` from .yarnrc, so a configured global folder that exists is crawled before the default one. The `yarn --version` probe now runs only when one of those folders exists, so a Yarn Berry machine (no `yarn global` at all) doesn't pay for a second yarn process on every global scan. Assisted-by: Claude Code:claude-opus-5-5 * Read yarn 1.0.x config from the probe dir The yarn 1.0.x fallback read global-folder settings from the current project, so a project .yarnrc naming an existing folder could pass a project directory off as the machine-wide yarn install. It now reads yarn's config from the same neutral directory the `yarn global dir` probe runs in. Assisted-by: Claude Code:claude-opus-5-5 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c62a2a2 commit 34f13e0

4 files changed

Lines changed: 265 additions & 5 deletions

File tree

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

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2343,12 +2343,47 @@ pub fn get_yarn_global_prefix() -> Option<String> {
23432343
/// Version of `get_yarn_global_prefix` that accepts an injected
23442344
/// `CommandRunner`. See `get_npm_global_prefix_with`.
23452345
pub fn get_yarn_global_prefix_with(runner: &dyn CommandRunner) -> Option<String> {
2346-
parse_yarn_dir_output(
2346+
get_yarn_global_prefix_or(
2347+
runner,
2348+
crate::patch::redirect::yarnrc::yarn_1_0_global_folders_for_process,
2349+
)
2350+
}
2351+
2352+
/// [`get_yarn_global_prefix_with`] with the yarn 1.0.x global folder
2353+
/// candidates injected. Yarn 1.0.0 - 1.0.2 has no `yarn global dir` (it
2354+
/// exits 1 with "Invalid subcommand"), so the first candidate that holds
2355+
/// a `node_modules` stands in for its answer when `yarn --version`
2356+
/// reports 1.0.x (#437). The version is asked only once such a folder
2357+
/// exists, so a yarn without the subcommand for another reason (Berry
2358+
/// has no `global`) costs no second spawn.
2359+
pub fn get_yarn_global_prefix_or(
2360+
runner: &dyn CommandRunner,
2361+
yarn_1_0_folders: impl FnOnce() -> Vec<PathBuf>,
2362+
) -> Option<String> {
2363+
let reported = parse_yarn_dir_output(
23472364
runner
23482365
.run("yarn", &["global", "dir"])
23492366
.as_deref()
23502367
.unwrap_or(""),
2351-
)
2368+
);
2369+
if reported.is_some() {
2370+
return reported;
2371+
}
2372+
let node_modules = yarn_1_0_folders()
2373+
.into_iter()
2374+
.map(|dir| dir.join("node_modules"))
2375+
.find(|dir| is_dir_sync(dir))?;
2376+
let version = runner.run("yarn", &["--version"])?;
2377+
is_yarn_1_0(&version).then(|| node_modules.to_string_lossy().to_string())
2378+
}
2379+
2380+
/// Whether `yarn --version` stdout names a 1.0.x release.
2381+
fn is_yarn_1_0(version: &str) -> bool {
2382+
version
2383+
.trim()
2384+
.strip_prefix("1.0.")
2385+
.and_then(|patch| patch.split(['-', '+']).next())
2386+
.is_some_and(|patch| !patch.is_empty() && patch.bytes().all(|b| b.is_ascii_digit()))
23522387
}
23532388

23542389
/// Pure parser for `yarn global dir` stdout. Returns `<dir>/node_modules`

‎crates/socket-patch-core/src/patch/redirect/yarnrc.rs‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -567,6 +567,41 @@ pub fn configured_yarn_global_folders_for_process() -> Vec<PathBuf> {
567567
)
568568
}
569569

570+
/// The global folder yarn 1.0.x uses by default, which it can't report:
571+
/// `yarn global dir` only exists from yarn 1.1.0 (#437). This is yarn
572+
/// 1.0.2's `GLOBAL_MODULE_DIRECTORY`, `<CONFIG_DIRECTORY>/global`, where
573+
/// the config directory is `%LOCALAPPDATA%\Yarn\config` on Windows (when
574+
/// `LOCALAPPDATA` is set) and `<userHome>/.config/yarn` otherwise.
575+
pub fn yarn_1_0_default_global_folder(env: &NpmConfigEnv, root_user: bool) -> Option<PathBuf> {
576+
if env.windows {
577+
if let Some(local) = env.var("LOCALAPPDATA") {
578+
return Some(Path::new(local).join("Yarn").join("config").join("global"));
579+
}
580+
}
581+
let user_home = RcLayout::new(env, root_user).user_home?;
582+
Some(user_home.join(".config").join("yarn").join("global"))
583+
}
584+
585+
/// Where yarn 1.0.x keeps this user's global packages, most likely
586+
/// first: any folder configured as yarn's global folder (yarn 1.0.x reads
587+
/// `--global-folder` from `.yarnrc`), then its default. The config is read
588+
/// as yarn run from the neutral probe dir sees it, like the `yarn global
589+
/// dir` probe it stands in for, so a project's `.yarnrc` can't answer
590+
/// where the machine-wide install lives.
591+
pub fn yarn_1_0_global_folders_for_process() -> Vec<PathBuf> {
592+
let env = NpmConfigEnv::from_process();
593+
let root_user = process_is_root_user();
594+
let mut folders = crate::utils::process::neutral_probe_dir()
595+
.map(|dir| {
596+
configured_yarn_global_folders(&env, root_user, &dir, |path| {
597+
crate::utils::fs::read_regular_to_string_sync(path).ok()
598+
})
599+
})
600+
.unwrap_or_default();
601+
folders.extend(yarn_1_0_default_global_folder(&env, root_user));
602+
folders
603+
}
604+
570605
/// Whether this process runs as uid 0 (yarn's `getuid() === 0`).
571606
pub fn process_is_root_user() -> bool {
572607
#[cfg(unix)]

‎crates/socket-patch-core/src/patch/redirect/yarnrc_tests.rs‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -610,3 +610,69 @@ fn global_folder_values_read_like_yarn() {
610610
paths(&["/opt/nq"])
611611
);
612612
}
613+
614+
/// #437: yarn 1.0.x can't report its global folder, so it is derived the
615+
/// way yarn 1.0.2's `constants.js` does: `<userHome>/.config/yarn/global`
616+
/// (`userHome` is `/usr/local/share` for a root user that isn't under
617+
/// fakeroot), or `%LOCALAPPDATA%\Yarn\config\global` on Windows.
618+
#[test]
619+
fn yarn_1_0_default_global_folder_follows_yarn_1_0_2() {
620+
let vars = |pairs: &[(&str, &str)]| -> Vec<(String, String)> {
621+
pairs
622+
.iter()
623+
.map(|(k, v)| (k.to_string(), v.to_string()))
624+
.collect()
625+
};
626+
let unix = NpmConfigEnv::from_parts(vars(&[("HOME", "/home/u")]), None, false);
627+
assert_eq!(
628+
yarn_1_0_default_global_folder(&unix, false),
629+
Some(PathBuf::from("/home/u/.config/yarn/global"))
630+
);
631+
assert_eq!(
632+
yarn_1_0_default_global_folder(&unix, true),
633+
Some(PathBuf::from("/usr/local/share/.config/yarn/global"))
634+
);
635+
let fakeroot = NpmConfigEnv::from_parts(
636+
vars(&[("HOME", "/home/u"), ("FAKEROOTKEY", "1")]),
637+
None,
638+
false,
639+
);
640+
assert_eq!(
641+
yarn_1_0_default_global_folder(&fakeroot, true),
642+
Some(PathBuf::from("/home/u/.config/yarn/global"))
643+
);
644+
assert_eq!(
645+
yarn_1_0_default_global_folder(&NpmConfigEnv::from_parts(Vec::new(), None, false), false),
646+
None,
647+
"no home, no folder"
648+
);
649+
650+
let windows = NpmConfigEnv::from_parts(
651+
vars(&[
652+
("USERPROFILE", r"C:\Users\u"),
653+
("LOCALAPPDATA", r"C:\Users\u\AppData\Local"),
654+
]),
655+
None,
656+
true,
657+
);
658+
assert_eq!(
659+
yarn_1_0_default_global_folder(&windows, false),
660+
Some(
661+
Path::new(r"C:\Users\u\AppData\Local")
662+
.join("Yarn")
663+
.join("config")
664+
.join("global")
665+
)
666+
);
667+
// Without LOCALAPPDATA yarn 1.0.x falls back to the home layout.
668+
let windows = NpmConfigEnv::from_parts(vars(&[("USERPROFILE", r"C:\Users\u")]), None, true);
669+
assert_eq!(
670+
yarn_1_0_default_global_folder(&windows, false),
671+
Some(
672+
Path::new(r"C:\Users\u")
673+
.join(".config")
674+
.join("yarn")
675+
.join("global")
676+
)
677+
);
678+
}

‎crates/socket-patch-core/tests/crawler_npm_e2e.rs‎

Lines changed: 127 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ use socket_patch_core::crawlers::npm_crawler::{
1010
build_npm_purl, bun_global_dir_from_env, find_store_peer_variant_copies, get_bun_global_prefix,
1111
get_bun_global_prefix_with, get_npm_global_prefix, get_npm_global_prefix_with,
1212
get_pnpm_global_prefix, get_pnpm_global_prefix_with, get_yarn_global_prefix,
13-
get_yarn_global_prefix_with, parse_bun_ls_global_output, parse_npm_root_output,
14-
parse_package_name, parse_pnpm_root_output, parse_yarn_dir_output, read_package_json,
15-
resolve_bun_global_prefix_with,
13+
get_yarn_global_prefix_or, get_yarn_global_prefix_with, parse_bun_ls_global_output,
14+
parse_npm_root_output, parse_package_name, parse_pnpm_root_output, parse_yarn_dir_output,
15+
read_package_json, resolve_bun_global_prefix_with,
1616
};
1717
use socket_patch_core::crawlers::types::CrawlerOptions;
1818
use socket_patch_core::crawlers::NpmCrawler;
@@ -352,6 +352,130 @@ fn get_yarn_global_prefix_with_mock_runner_success() {
352352
);
353353
}
354354

355+
/// Sets environment variables for a test and puts the old values back on
356+
/// drop, a panic included.
357+
struct EnvGuard(Vec<(&'static str, Option<std::ffi::OsString>)>);
358+
359+
impl EnvGuard {
360+
fn set(vars: &[(&'static str, &Path)]) -> Self {
361+
let saved = vars
362+
.iter()
363+
.map(|(k, v)| {
364+
let old = std::env::var_os(k);
365+
std::env::set_var(k, v);
366+
(*k, old)
367+
})
368+
.collect();
369+
EnvGuard(saved)
370+
}
371+
}
372+
373+
impl Drop for EnvGuard {
374+
fn drop(&mut self) {
375+
for (k, v) in self.0.drain(..) {
376+
match v {
377+
Some(v) => std::env::set_var(k, v),
378+
None => std::env::remove_var(k),
379+
}
380+
}
381+
}
382+
}
383+
384+
/// #437: yarn 1.0.0 - 1.0.2 has no `yarn global dir` (it exits 1), so
385+
/// the folder yarn 1.0.x itself uses stands in for the answer it can't
386+
/// give. Real process environment: HOME points at a scratch home and
387+
/// FAKEROOTKEY keeps a root test runner on the plain-home layout.
388+
#[cfg(unix)]
389+
#[test]
390+
#[serial_test::serial]
391+
fn get_yarn_global_prefix_with_falls_back_for_yarn_1_0() {
392+
let home = tempfile::tempdir().unwrap();
393+
let global = home.path().join(".config/yarn/global/node_modules");
394+
std::fs::create_dir_all(global.join("left-pad")).unwrap();
395+
let _env = EnvGuard::set(&[("HOME", home.path()), ("FAKEROOTKEY", Path::new("1"))]);
396+
let runner = common::MockCommandRunner::new()
397+
.with_response("yarn", &["global", "dir"], None)
398+
.with_response("yarn", &["--version"], Some("1.0.2\n"));
399+
assert_eq!(
400+
get_yarn_global_prefix_with(&runner).map(PathBuf::from),
401+
Some(global.clone())
402+
);
403+
404+
// A project's `.yarnrc` in the cwd names a global folder that exists:
405+
// it can't stand in for the machine-wide answer, which yarn run from
406+
// the neutral probe dir (HOME) never sees.
407+
let project = tempfile::tempdir().unwrap();
408+
std::fs::create_dir_all(project.path().join("g/node_modules")).unwrap();
409+
std::fs::write(project.path().join(".yarnrc"), "--global-folder \"./g\"\n").unwrap();
410+
let cwd = std::env::current_dir().unwrap();
411+
std::env::set_current_dir(project.path()).unwrap();
412+
let got = get_yarn_global_prefix_with(&runner);
413+
std::env::set_current_dir(cwd).unwrap();
414+
assert_eq!(got.map(PathBuf::from), Some(global));
415+
}
416+
417+
/// The fallback is for yarn 1.0.x only: a newer yarn whose
418+
/// `yarn global dir` failed, or no yarn at all, still finds nothing. The
419+
/// first candidate folder that exists wins, and none existing finds
420+
/// nothing without asking yarn for its version.
421+
#[test]
422+
#[serial_test::parallel]
423+
fn get_yarn_global_prefix_or_falls_back_only_for_yarn_1_0() {
424+
let tmp = tempfile::tempdir().unwrap();
425+
let configured = tmp.path().join("configured");
426+
let default = tmp.path().join("default");
427+
std::fs::create_dir_all(default.join("node_modules")).unwrap();
428+
let folders = || vec![configured.clone(), default.clone()];
429+
let want = default.join("node_modules");
430+
for version in ["1.0.0", "1.0.1\n", "1.0.2", "1.0.2-rc.1"] {
431+
let runner = common::MockCommandRunner::new()
432+
.with_response("yarn", &["global", "dir"], None)
433+
.with_response("yarn", &["--version"], Some(version));
434+
assert_eq!(
435+
get_yarn_global_prefix_or(&runner, folders).map(PathBuf::from),
436+
Some(want.clone()),
437+
"{version:?}"
438+
);
439+
}
440+
for version in [
441+
Some("1.1.0"),
442+
Some("1.22.22"),
443+
Some("1.0"),
444+
Some("4.5.0"),
445+
None,
446+
] {
447+
let runner = common::MockCommandRunner::new()
448+
.with_response("yarn", &["global", "dir"], None)
449+
.with_response("yarn", &["--version"], version);
450+
assert_eq!(
451+
get_yarn_global_prefix_or(&runner, folders),
452+
None,
453+
"{version:?}"
454+
);
455+
}
456+
// A configured global folder that exists comes before the default.
457+
std::fs::create_dir_all(configured.join("node_modules")).unwrap();
458+
let runner = common::MockCommandRunner::new()
459+
.with_response("yarn", &["global", "dir"], None)
460+
.with_response("yarn", &["--version"], Some("1.0.2"));
461+
assert_eq!(
462+
get_yarn_global_prefix_or(&runner, folders).map(PathBuf::from),
463+
Some(configured.join("node_modules"))
464+
);
465+
// No candidate folder: no version probe is needed (the mock has no
466+
// answer for it either way).
467+
let runner = common::MockCommandRunner::new().with_response("yarn", &["global", "dir"], None);
468+
assert_eq!(get_yarn_global_prefix_or(&runner, Vec::new), None);
469+
// A yarn that answers `yarn global dir` is taken at its word.
470+
let runner = common::MockCommandRunner::new()
471+
.with_response("yarn", &["global", "dir"], Some("/other/global\n"))
472+
.with_response("yarn", &["--version"], Some("1.0.2"));
473+
assert_eq!(
474+
get_yarn_global_prefix_or(&runner, folders).map(PathBuf::from),
475+
Some(Path::new("/other/global").join("node_modules"))
476+
);
477+
}
478+
355479
#[test]
356480
#[serial_test::parallel]
357481
fn get_pnpm_global_prefix_with_mock_runner_success() {

0 commit comments

Comments
 (0)