Skip to content

Commit 4b05ab2

Browse files
Fix yarn global folder moved by global-folder (#1629) (#1696)
* Start fix for #1629 Assisted-by: Claude Code:claude-opus-5-5 * Fix --update hint for a moved yarn global folder A `yarn global add` install whose global folder was moved with yarn's `global-folder` setting (YARN_GLOBAL_FOLDER, npm_config_global_folder, ~/.yarnrc, ~/.npmrc, ...) to a folder not named `global` looked like a small yarn project, so `socket-patch --update` told the user to run `yarn add` from wherever they were. That edits some other project and leaves the installed copy stale. Self-update now reads the `global-folder` values yarn 1 reads, resolves each path the way yarn 1.22 does, and treats an install inside one of those folders as yarn's global folder: `yarn global add …@latest`. Assisted-by: Claude Code:claude-opus-5-5 * Read yarn's global-folder like yarn does Review follow-ups for the moved yarn global folder fix: - Accept a `--global-folder` default flag in a .yarnrc. - Read a .yarnrc value as one token of yarn's rc grammar, so a trailing `# comment` is dropped and a double-quoted value has its escapes decoded (Windows paths). - Decode an .npmrc value like npm's ini parser (inline comments, quotes, escapes) and expand its ${NAME} references like yarn. - Read yarn's config only when a yarn install is being classified, so other install channels never walk the rc files. Assisted-by: Claude Code:claude-opus-5-5 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6f6c6d3 commit 4b05ab2

7 files changed

Lines changed: 778 additions & 128 deletions

File tree

‎crates/socket-patch-cli/src/commands/update.rs‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -219,8 +219,9 @@ pub async fn run(args: UpdateArgs) -> i32 {
219219
Ok(p) => p,
220220
Err(e) => return fail(&args, e.error_code(), &e.to_string()).await,
221221
};
222-
let channel = detect_channel(&install_path, &ChannelEnv::from_env());
223-
let hint = upgrade_hint_for(channel, &install_path);
222+
let channel_env = ChannelEnv::from_env();
223+
let channel = detect_channel(&install_path, &channel_env);
224+
let hint = upgrade_hint_for(channel, &install_path, &channel_env);
224225
if channel != InstallChannel::Standalone {
225226
if args.force {
226227
note_warning(
@@ -230,7 +231,7 @@ pub async fn run(args: UpdateArgs) -> i32 {
230231
format!(
231232
"this install is managed by {} — its next upgrade will overwrite \
232233
the updated binary.",
233-
channel_label_for(channel, &install_path)
234+
channel_label_for(channel, &install_path, &channel_env)
234235
),
235236
);
236237
} else {
@@ -241,7 +242,7 @@ pub async fn run(args: UpdateArgs) -> i32 {
241242
"this socket-patch binary ({}) is managed by {}; update it with `{}` \
242243
instead, or pass --force to replace it in place",
243244
install_path.display(),
244-
channel_label_for(channel, &install_path),
245+
channel_label_for(channel, &install_path, &channel_env),
245246
hint
246247
),
247248
)

‎crates/socket-patch-cli/src/update_notifier.rs‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,10 @@ async fn refresh_latest(debug: bool) -> Option<semver::Version> {
240240
/// its managed-install refusal.
241241
fn upgrade_command() -> &'static str {
242242
match core_update::resolve_install_path() {
243-
Ok(p) => upgrade_hint_for(detect_channel(&p, &ChannelEnv::from_env()), &p),
243+
Ok(p) => {
244+
let env = ChannelEnv::from_env();
245+
upgrade_hint_for(detect_channel(&p, &env), &p, &env)
246+
}
244247
Err(_) => upgrade_hint(InstallChannel::Standalone),
245248
}
246249
}

‎crates/socket-patch-cli/tests/update/self_update_channels_e2e.rs‎

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,99 @@ async fn yarn_global_refuses_with_yarn_hint() {
211211
install.assert_binary_intact();
212212
}
213213

214+
/// #1629: a yarn global folder moved by yarn's `global-folder` setting
215+
/// to a dir not named `global` looks like a small yarn project on disk
216+
/// (yarn writes its package.json and yarn.lock there). Yarn's config
217+
/// names it, so the refusal must still say `yarn global add`: `yarn add`
218+
/// run from the user's cwd would edit some project and leave this copy
219+
/// stale.
220+
#[tokio::test]
221+
async fn yarn_configured_global_folder_refuses_with_yarn_global_hint() {
222+
let rel_bin = "tools/yarn-g/node_modules/@socketsecurity/socket-patch-x/bin";
223+
let staged = || {
224+
let install = staged_install_at(rel_bin);
225+
let folder = install.root.path().join("tools/yarn-g");
226+
std::fs::write(
227+
folder.join("package.json"),
228+
r#"{"dependencies":{"@socketsecurity/socket-patch":"^4.0.0"}}"#,
229+
)
230+
.unwrap();
231+
std::fs::write(folder.join("yarn.lock"), "# yarn lockfile v1\n").unwrap();
232+
let home = install.root.path().join("home");
233+
std::fs::create_dir_all(&home).unwrap();
234+
(install, folder, home)
235+
};
236+
let refuse = |install: &update_fixture::StagedInstall, env: &[(&str, &str)], case: &str| {
237+
let mut env = env.to_vec();
238+
env.push(("SOCKET_UPDATE_BASE_URL", DEAD_BASE_URL));
239+
let (code, _stdout, stderr) = run_installed(install, &["--update", "--yes"], &env);
240+
assert_eq!(code, 1, "{case}: managed install must refuse.\n{stderr}");
241+
assert!(stderr.contains("managed by yarn"), "{case}: {stderr}");
242+
assert!(
243+
stderr.contains("`yarn global add @socketsecurity/socket-patch@latest`"),
244+
"{case}: a configured yarn global folder must get `yarn global add`: {stderr}"
245+
);
246+
install.assert_binary_intact();
247+
};
248+
249+
// `YARN_GLOBAL_FOLDER` (`yarn_*` env, any case).
250+
let (install, folder, home) = staged();
251+
let (folder, home) = (folder.display().to_string(), home.display().to_string());
252+
for var in [
253+
"YARN_GLOBAL_FOLDER",
254+
"yarn_global_folder",
255+
"npm_config_global_folder",
256+
] {
257+
refuse(
258+
&install,
259+
&[("HOME", &home), ("USERPROFILE", &home), (var, &folder)],
260+
var,
261+
);
262+
}
263+
264+
// `global-folder` in the user's `~/.yarnrc` and `~/.npmrc`.
265+
let (install, folder, home) = staged();
266+
std::fs::write(
267+
home.join(".yarnrc"),
268+
format!("global-folder \"{}\"\n", folder.display()).replace('\\', "/"),
269+
)
270+
.unwrap();
271+
let home_s = home.display().to_string();
272+
refuse(
273+
&install,
274+
&[("HOME", &home_s), ("USERPROFILE", &home_s)],
275+
"~/.yarnrc",
276+
);
277+
std::fs::remove_file(home.join(".yarnrc")).unwrap();
278+
std::fs::write(
279+
home.join(".npmrc"),
280+
format!("global-folder={}\n", folder.display()).replace('\\', "/"),
281+
)
282+
.unwrap();
283+
refuse(
284+
&install,
285+
&[("HOME", &home_s), ("USERPROFILE", &home_s)],
286+
"~/.npmrc",
287+
);
288+
289+
// Without any config the folder is a yarn 1 project, as before.
290+
std::fs::remove_file(home.join(".npmrc")).unwrap();
291+
let (code, _stdout, stderr) = run_installed(
292+
&install,
293+
&["--update", "--yes"],
294+
&[
295+
("HOME", &home_s),
296+
("USERPROFILE", &home_s),
297+
("SOCKET_UPDATE_BASE_URL", DEAD_BASE_URL),
298+
],
299+
);
300+
assert_eq!(code, 1, "{stderr}");
301+
assert!(
302+
stderr.contains("`yarn add @socketsecurity/socket-patch@latest`"),
303+
"{stderr}"
304+
);
305+
}
306+
214307
/// An npm-bundled binary (any `node_modules` component) refuses with the
215308
/// npm upgrade command — and the refusal happens before ANY release
216309
/// traffic: a fully valid, newer release is mounted and its routes must

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -359,7 +359,7 @@ impl NpmConfigEnv {
359359

360360
/// A variable as npm's `process.env[name]` reads it: exact case on
361361
/// Unix, case-insensitive on Windows. `Some("")` when set but empty.
362-
fn var_raw(&self, name: &str) -> Option<&str> {
362+
pub(crate) fn var_raw(&self, name: &str) -> Option<&str> {
363363
self.vars
364364
.iter()
365365
.find(|(k, _)| {

0 commit comments

Comments
 (0)