diff --git a/crates/socket-patch-cli/tests/e2e_redirect_yarn_berry_build.rs b/crates/socket-patch-cli/tests/e2e_redirect_yarn_berry_build.rs index e021d9015..c1ae3226c 100644 --- a/crates/socket-patch-cli/tests/e2e_redirect_yarn_berry_build.rs +++ b/crates/socket-patch-cli/tests/e2e_redirect_yarn_berry_build.rs @@ -273,6 +273,9 @@ struct BerryRedirectFixture { /// The root `package.json` BEFORE the hosted rewrite (#404 option C /// pins through its `resolutions`, so a revert restores both files). registry_pkg: Vec, + /// The dependency is declared `"catalog:"` (#632): `.yarnrc.yml` keeps + /// the catalog in every checkout. + catalog: bool, _server: MockServer, } @@ -298,6 +301,17 @@ async fn berry_hosted_project( tag: &str, tamper_served_tarball: bool, driver: HostedDriver, +) -> Option { + berry_hosted_project_with(tag, tamper_served_tarball, driver, false).await +} + +/// [`berry_hosted_project`], with the dependency declared through the +/// default yarn catalog when `catalog` is set (#632). +async fn berry_hosted_project_with( + tag: &str, + tamper_served_tarball: bool, + driver: HostedDriver, + catalog: bool, ) -> Option { if !has_corepack_pm(yarn_berry()) { skip!( @@ -317,13 +331,17 @@ async fn berry_hosted_project( std::fs::write( proj.join("package.json"), format!( - r#"{{"name":"redirect-berry-capstone","version":"0.0.0","private":true,"dependencies":{{"{DEP}":"{DEP_VERSION}"}}}}"# + r#"{{"name":"redirect-berry-capstone","version":"0.0.0","private":true,"dependencies":{{"{DEP}":"{}"}}}}"#, + if catalog { "catalog:" } else { DEP_VERSION } ), ) .unwrap(); std::fs::write( proj.join(".yarnrc.yml"), - "nodeLinker: node-modules\nenableGlobalCache: false\n", + format!( + "nodeLinker: node-modules\nenableGlobalCache: false\n{}", + catalog_yarnrc(catalog) + ), ) .unwrap(); @@ -580,6 +598,13 @@ async fn berry_hosted_project( && v.as_str() == Some(hosted_url.as_str()))), "package.json must route {DEP} to the hosted tarball: {root_pkg}" ); + if catalog { + assert_eq!( + root_pkg["resolutions"][format!("{DEP}@catalog:")], + hosted_url.as_str(), + "#632: the catalog descriptor yarn matches is routed too: {root_pkg}" + ); + } assert!( !lock.contains("__archiveUrl"), "the hosted pin must not be an npm: locator; got:\n{lock}" @@ -608,6 +633,7 @@ async fn berry_hosted_project( host, registry_lock, registry_pkg, + catalog, _server: server, }) } @@ -620,11 +646,21 @@ fn fresh_yarnrc(fx: &BerryRedirectFixture) -> String { format!( "nodeLinker: node-modules\nenableGlobalCache: false\n\ unsafeHttpWhitelist:\n - \"{}\"\n\ - npmRegistryServer: \"http://127.0.0.1:1\"\n", - fx.host.split(':').next().unwrap_or("127.0.0.1") + npmRegistryServer: \"http://127.0.0.1:1\"\n{}", + fx.host.split(':').next().unwrap_or("127.0.0.1"), + catalog_yarnrc(fx.catalog) ) } +/// The `.yarnrc.yml` default catalog of a `"catalog:"` fixture (#632). +fn catalog_yarnrc(catalog: bool) -> String { + if catalog { + format!("catalog:\n {DEP}: {DEP_VERSION}\n") + } else { + String::new() + } +} + /// Fresh dir with only the committable files, then `yarn install --immutable /// --check-cache` offline-from-registry (the wiremock host is whitelisted for /// http). The install runs with an npm registry token that yarn must apply to @@ -790,6 +826,47 @@ async fn berry_redirect_fresh_checkout_installs_patched_bytes() { hosted_manifestless_vex_matrix(&fx, HostedDriver::Scan); } +/// #632: a dependency declared through a yarn catalog (`"catalog:"`, yarn +/// >= 4.10). Yarn matches `resolutions` before it expands the catalog, so a +/// pin routing only the expanded `npm:` descriptor left the fresh +/// `--immutable` install failing YN0028 (and a mutable one unpatched). The +/// fresh checkout must install the patched bytes from the hosted tarball. +#[tokio::test(flavor = "multi_thread")] +#[serial_test::serial] +async fn berry_redirect_catalog_dependency_fresh_checkout_installs() { + let release = yarn_berry().strip_prefix("yarn@").unwrap_or(yarn_berry()); + let minor: Vec = release + .split('.') + .take(2) + .filter_map(|p| p.parse().ok()) + .collect(); + if minor.as_slice() < [4, 10].as_slice() { + // Not a skip of an available toolchain: catalogs do not exist before + // yarn 4.10, so there is nothing to exercise. + println!("SKIP berry catalog e2e: {release} predates yarn catalogs (4.10)"); + return; + } + let Some(fx) = berry_hosted_project_with("catalog", false, HostedDriver::Scan, true).await + else { + return; + }; + + let (fresh, ci) = fresh_checkout_yarn_install(&fx); + assert!( + ci.status.success(), + "fresh-checkout `yarn install --immutable --check-cache` of a catalog dependency \ + must succeed from the hosted patch tarball.\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&ci.stdout), + String::from_utf8_lossy(&ci.stderr), + ); + let installed = std::fs::read(fresh.join("node_modules").join(DEP).join("index.js")).unwrap(); + assert_eq!( + installed, fx.patched, + "fresh install of the catalog dependency must be the patched content" + ); + assert_patch_host_got_no_auth(&fx).await; +} + /// get-driven hosted twin (v4.0): `get --mode hosted --json --yes` /// routes through the SAME hosted engine as `scan --mode hosted`, so the /// berry chain must hold unchanged — including the `10c0` cacheKey bootstrap diff --git a/crates/socket-patch-cli/tests/in_process_redirect.rs b/crates/socket-patch-cli/tests/in_process_redirect.rs index 7ae650809..a0a5b427f 100644 --- a/crates/socket-patch-cli/tests/in_process_redirect.rs +++ b/crates/socket-patch-cli/tests/in_process_redirect.rs @@ -863,6 +863,94 @@ async fn scan_redirect_rewrites_crlf_and_bom_yarn_berry_locks_and_rollback_resto } /// #404 upgrade path: a lock pinned by an earlier release carries the old +/// #632: a dependency declared through a yarn catalog (`"catalog:"`) is +/// matched by yarn's `resolutions` before the catalog is expanded, so the +/// hosted pin must also route `@catalog:`; `rollback` must drop every +/// selector it wrote and restore the lock's expanded `npm:` key, leaving +/// package.json byte-identical to the pristine one. +#[tokio::test] +#[serial] +async fn yarn_berry_catalog_dependency_is_pinned_and_rolled_back() { + let server = MockServer::start().await; + mock_discovery(&server).await; + let hosted_url = HOSTED_URL.replace("http://patch.test", &server.uri()); + mock_reference_with_berry_url(&server, &hosted_url).await; + mock_view(&server).await; + let tarball = upstream_tarball(); + mock_npm_registry( + &server, + &vlt_hosted_common::sha512_sri(&tarball), + Some(tarball), + ) + .await; + + let tmp = tempfile::tempdir().unwrap(); + write_berry_project(tmp.path()); + let pkg_path = tmp.path().join("package.json"); + std::fs::write( + &pkg_path, + format!( + "{{\n \"name\": \"consumer\",\n \"version\": \"0.0.0\",\n \ + \"dependencies\": {{\n \"{NAME}\": \"catalog:\"\n }}\n}}\n" + ), + ) + .unwrap(); + std::fs::write( + tmp.path().join(".yarnrc.yml"), + format!("nodeLinker: node-modules\ncatalog:\n {NAME}: ^{VERSION}\n"), + ) + .unwrap(); + let pristine_pkg = std::fs::read_to_string(&pkg_path).unwrap(); + let lock_path = tmp.path().join("yarn.lock"); + let pristine_lock = std::fs::read_to_string(&lock_path).unwrap(); + + let env = run_redirect_subprocess_with( + tmp.path(), + &server.uri(), + &["--patch-server-url", &server.uri()], + ); + assert_eq!(env["redirect"]["redirected"], 1, "{env:#}"); + assert!(warning_codes(&env).is_empty(), "{env:#}"); + let pkg: serde_json::Value = + serde_json::from_str(&std::fs::read_to_string(&pkg_path).unwrap()).unwrap(); + assert_eq!( + pkg["resolutions"], + serde_json::json!({ + format!("{NAME}@npm:^{VERSION}"): hosted_url, + format!("{NAME}@catalog:"): hosted_url, + }), + "{pkg}" + ); + let lock = std::fs::read_to_string(&lock_path).unwrap(); + assert!( + lock.contains(&format!("\"{NAME}@{hosted_url}\":")), + "the entry is keyed by the tarball descriptor: {lock}" + ); + + let (code, env) = rollback_json_with_origin(tmp.path(), &server, &server.uri()); + assert_eq!(code, Some(0), "rollback: {env:#}"); + assert_eq!( + env["hosted"]["reverted"], + serde_json::json!([PURL]), + "{env:#}" + ); + assert_eq!( + std::fs::read_to_string(&pkg_path).unwrap(), + pristine_pkg, + "rollback drops both selectors" + ); + let restored = std::fs::read_to_string(&lock_path).unwrap(); + let checksum = berry_checksum_of(&restored); + assert_eq!( + restored, + pristine_lock.replace( + &format!("10c0/{}", "3".repeat(128)), + &format!("10c0/{checksum}") + ), + "rollback restores the expanded npm: key" + ); +} + /// `npm:::__archiveUrl=` resolution, which makes yarn's npm fetcher /// send registry auth to the patch host. `rollback` must still recognize and /// restore that legacy pin, and a repeat hosted `scan` must re-pin it to the diff --git a/crates/socket-patch-core/src/patch/redirect/mod.rs b/crates/socket-patch-core/src/patch/redirect/mod.rs index c5b565053..d14775d81 100644 --- a/crates/socket-patch-core/src/patch/redirect/mod.rs +++ b/crates/socket-patch-core/src/patch/redirect/mod.rs @@ -3456,6 +3456,9 @@ fn rewrite_yarn_berry( // the hosted tarball (see the section header). Parsed once; written back // in its own layout when a pin changes it. let manifest_text = files.get(BERRY_MANIFEST).map(String::as_str); + // Its `catalog:` / `catalogs:` tables: a dependency declared `catalog:` + // needs a selector yarn matches before it expands the catalog. + let yarnrc = files.get(".yarnrc.yml").map(String::as_str); let mut manifest: Option = manifest_text .and_then(|t| serde_json::from_str::(t.strip_prefix('\u{feff}').unwrap_or(t)).ok()) .filter(Value::is_object); @@ -3771,6 +3774,7 @@ fn rewrite_yarn_berry( }; let pin = match berry_resolutions_pin( manifest_obj, + yarnrc, &fname, &dep.version, &key_ranges, @@ -4026,6 +4030,17 @@ impl BerryResolutionsPin { /// tarball URL they are empty and recovered from our selectors routed to /// `current_url`. /// +/// Yarn matches `resolutions` against a dependency's descriptor as the +/// manifest spells it, before a `catalog:` descriptor is expanded to its +/// catalog range (#632): a dependency declared `"left-pad": "catalog:"` is +/// never matched by `left-pad@npm:^1.3.0`, though the lock keys its entry by +/// that expanded range. So every `.yarnrc.yml` catalog (`yarnrc`) whose range +/// for `name` is one of the pinned npm ranges is also routed, as +/// `name@catalog:` (the default `catalog:` table) or `name@catalog:` +/// (`catalogs.`). The `npm:` selectors stay: transitive dependents +/// still ask for the expanded range, and rollback rebuilds the lock key from +/// them. +/// /// Refuses (fail closed, nothing written) when the manifest already carries /// a user-authored `resolutions` entry for the package — any selector whose /// target is `name`, bare or scoped — since yarn would apply it alongside @@ -4033,6 +4048,7 @@ impl BerryResolutionsPin { /// user pinned. fn berry_resolutions_pin( manifest: &serde_json::Map, + yarnrc: Option<&str>, name: &str, version: &str, key_ranges: &[String], @@ -4072,7 +4088,7 @@ fn berry_resolutions_pin( ), }); } - let selectors: Vec = if key_ranges.is_empty() { + let mut selectors: Vec = if key_ranges.is_empty() { ours.iter() .filter(|(_, value)| value.as_str().is_some() && value.as_str() == current_url) .map(|(selector, _)| (*selector).clone()) @@ -4092,6 +4108,17 @@ fn berry_resolutions_pin( ), }); } + let ranges: Vec<&str> = selectors + .iter() + .filter_map(|selector| crate::vendor::yarn_classic_lock::split_pattern(selector)) + .filter(|(n, range)| *n == name && range.starts_with("npm:")) + .map(|(_, range)| range) + .collect(); + for selector in berry_catalog_selectors(yarnrc, name, &ranges) { + if !selectors.contains(&selector) { + selectors.push(selector); + } + } let stale = ours .iter() .filter(|(selector, value)| { @@ -4105,6 +4132,53 @@ fn berry_resolutions_pin( Ok(BerryResolutionsPin { selectors, stale }) } +/// The `name@catalog:` / `name@catalog:` selectors of every +/// `.yarnrc.yml` catalog that maps `name` to one of `ranges` (yarn's `npm:` +/// spellings, as the lock keys them). A catalog range is normalized the way +/// yarn normalizes a manifest range: one without a protocol is an `npm:` +/// range. A `.yarnrc.yml` that is absent or not YAML has no catalogs (yarn +/// itself refuses to run on one it cannot parse). +fn berry_catalog_selectors(yarnrc: Option<&str>, name: &str, ranges: &[&str]) -> Vec { + #[derive(serde::Deserialize, Default)] + struct Catalogs { + #[serde(default)] + catalog: Option>, + #[serde(default)] + catalogs: Option>, + } + let Some(rc) = yarnrc else { + return Vec::new(); + }; + let rc = rc.strip_prefix('\u{feff}').unwrap_or(rc); + let Ok(parsed) = serde_saphyr::from_str::>(rc) else { + return Vec::new(); + }; + let parsed = parsed.unwrap_or_default(); + let pins = |table: Option<&serde_json::Map>| { + let range = match table.and_then(|t| t.get(name)) { + Some(Value::String(range)) => range.trim().to_string(), + Some(Value::Number(n)) => n.to_string(), + _ => return false, + }; + let range = if range.contains(':') { + range + } else { + format!("npm:{range}") + }; + ranges.contains(&range.as_str()) + }; + let mut selectors = Vec::new(); + if pins(parsed.catalog.as_ref()) { + selectors.push(format!("{name}@catalog:")); + } + for (catalog, table) in parsed.catalogs.iter().flatten() { + if pins(table.as_object()) { + selectors.push(format!("{name}@catalog:{catalog}")); + } + } + selectors +} + /// Move each entry keyed `moved` to where yarn sorts it. Yarn writes lock /// entries sorted by their (unquoted) key — `__metadata` first — so an entry /// re-keyed from `name@npm:…` to `name@` can move past a sibling (e.g. @@ -8196,6 +8270,180 @@ mod tests { ); } + /// A root manifest consuming left-pad through the default catalog. + fn berry_catalog_manifest() -> String { + "{\n \"name\": \"app\",\n \"version\": \"1.0.0\",\n \"dependencies\": {\n \ + \"left-pad\": \"catalog:\"\n }\n}\n" + .to_string() + } + + /// #632: yarn matches `resolutions` against the manifest's `catalog:` + /// descriptor before it expands the catalog, so a pin keyed only by the + /// lock's expanded `left-pad@npm:^1.3.0` never applies to a catalog + /// dependency: `yarn install --immutable` fails YN0028 and a mutable + /// install is unpatched. The catalog's own selector is routed too; the + /// `npm:` one stays for transitive descriptors and for rollback. + #[test] + fn yarn_berry_pin_routes_a_default_catalog_dependency() { + let checksum = format!("10c0/{}", "7".repeat(128)); + let url = berry_hosted_url("left-pad", "left-pad", "1.3.0"); + let ovr = berry_override("left-pad", "1.3.0", &url, &checksum); + for yarnrc in [ + "nodeLinker: node-modules\ncatalog:\n left-pad: ^1.3.0\n", + "\u{feff}catalog:\r\n left-pad: \"npm:^1.3.0\"\r\n", + ] { + let mut files = berry_files(berry_lock("10c0"), berry_catalog_manifest()); + files.insert(".yarnrc.yml".to_string(), yarnrc.to_string()); + let mut r = RewriteResult::default(); + rewrite_yarn_berry(&files, std::slice::from_ref(&ovr), &mut r); + assert!(r.warnings.is_empty(), "{yarnrc:?}: {:?}", r.warnings); + let manifest: Value = serde_json::from_str(&r.files["package.json"]).unwrap(); + assert_eq!( + manifest["resolutions"], + json!({"left-pad@npm:^1.3.0": url, "left-pad@catalog:": url}), + "{yarnrc:?}: {manifest}" + ); + assert_eq!(manifest["dependencies"]["left-pad"], "catalog:"); + let keys: Vec<_> = r + .edits + .iter() + .filter(|e| e.kind == "redirect_yarn_berry_resolution") + .filter_map(|e| e.key.as_deref()) + .collect(); + assert_eq!( + keys, + ["left-pad@npm:^1.3.0", "left-pad@catalog:"], + "{yarnrc:?}" + ); + assert!(r.confirmed_yarn_berry_uuids.contains(&ovr.patch_uuid)); + } + } + + /// #632, workspace shape: the default catalog and a named one + /// (`catalogs.legacy`) both lock into one merged entry, so both catalog + /// selectors are routed; a catalog whose range locks another entry, a + /// catalog of another package and one with a non-npm protocol are not. + #[test] + fn yarn_berry_pin_routes_every_catalog_locking_the_entry() { + let checksum = format!("10c0/{}", "7".repeat(128)); + let url = berry_hosted_url("left-pad", "left-pad", "1.3.0"); + let ovr = berry_override("left-pad", "1.3.0", &url, &checksum); + let lock = format!( + "# header\n\n__metadata:\n version: 8\n cacheKey: 10c0\n\n\ + \"left-pad@npm:1.3.0, left-pad@npm:^1.3.0\":\n version: 1.3.0\n \ + resolution: \"left-pad@npm:1.3.0\"\n checksum: 10c0/{}\n languageName: node\n \ + linkType: hard\n", + "3".repeat(128) + ); + let mut files = berry_files(lock, berry_catalog_manifest()); + files.insert( + ".yarnrc.yml".to_string(), + "catalog:\n left-pad: ^1.3.0\n is-number: 1.3.0\ncatalogs:\n legacy:\n \ + left-pad: 1.3.0\n old:\n left-pad: ^1.0.0\n forked:\n \ + left-pad: \"patch:left-pad@npm%3A1.3.0#./p.patch\"\n" + .to_string(), + ); + let mut r = RewriteResult::default(); + rewrite_yarn_berry(&files, std::slice::from_ref(&ovr), &mut r); + assert!(r.warnings.is_empty(), "{:?}", r.warnings); + let manifest: Value = serde_json::from_str(&r.files["package.json"]).unwrap(); + assert_eq!( + manifest["resolutions"], + json!({ + "left-pad@npm:1.3.0": url, + "left-pad@npm:^1.3.0": url, + "left-pad@catalog:": url, + "left-pad@catalog:legacy": url, + }), + "{manifest}" + ); + } + + /// #632: a re-run over its own catalog pin changes nothing, and a re-run + /// over a pin written before the fix (lock keyed by the URL, only the + /// `npm:` selector) adds the missing catalog selector without touching + /// the lock. + #[test] + fn yarn_berry_catalog_pin_rerun_is_stable_and_heals_an_old_pin() { + let checksum = format!("10c0/{}", "7".repeat(128)); + let url = berry_hosted_url("left-pad", "left-pad", "1.3.0"); + let ovr = berry_override("left-pad", "1.3.0", &url, &checksum); + let yarnrc = "catalog:\n left-pad: ^1.3.0\n"; + let mut files = berry_files(berry_lock("10c0"), berry_catalog_manifest()); + files.insert(".yarnrc.yml".to_string(), yarnrc.to_string()); + let mut first = RewriteResult::default(); + rewrite_yarn_berry(&files, std::slice::from_ref(&ovr), &mut first); + assert!(first.warnings.is_empty(), "{:?}", first.warnings); + let pinned_lock = first.files["yarn.lock"].clone(); + let pinned_manifest = first.files["package.json"].clone(); + + let mut rerun_files = berry_files(pinned_lock.clone(), pinned_manifest); + rerun_files.insert(".yarnrc.yml".to_string(), yarnrc.to_string()); + let mut rerun = RewriteResult::default(); + rewrite_yarn_berry(&rerun_files, std::slice::from_ref(&ovr), &mut rerun); + assert!(rerun.warnings.is_empty(), "{:?}", rerun.warnings); + assert!( + rerun.files.is_empty(), + "re-run is a no-op: {:?}", + rerun.files + ); + assert!(rerun.confirmed_yarn_berry_uuids.contains(&ovr.patch_uuid)); + + let old_manifest = serde_json::to_string_pretty(&json!({ + "name": "app", + "version": "1.0.0", + "dependencies": {"left-pad": "catalog:"}, + "resolutions": {"left-pad@npm:^1.3.0": url}, + })) + .unwrap() + + "\n"; + let mut old_files = berry_files(pinned_lock, old_manifest); + old_files.insert(".yarnrc.yml".to_string(), yarnrc.to_string()); + let mut healed = RewriteResult::default(); + rewrite_yarn_berry(&old_files, std::slice::from_ref(&ovr), &mut healed); + assert!(healed.warnings.is_empty(), "{:?}", healed.warnings); + assert!( + !healed.files.contains_key("yarn.lock"), + "lock already pinned" + ); + let manifest: Value = serde_json::from_str(&healed.files["package.json"]).unwrap(); + assert_eq!( + manifest["resolutions"], + json!({"left-pad@npm:^1.3.0": url, "left-pad@catalog:": url}), + "{manifest}" + ); + } + + /// A user-authored catalog selector is the user's pin: the hosted + /// redirect refuses rather than overwrite it. + #[test] + fn yarn_berry_pin_refuses_a_user_catalog_resolution() { + let checksum = format!("10c0/{}", "7".repeat(128)); + let url = berry_hosted_url("left-pad", "left-pad", "1.3.0"); + let ovr = berry_override("left-pad", "1.3.0", &url, &checksum); + let manifest = serde_json::to_string_pretty(&json!({ + "name": "app", + "dependencies": {"left-pad": "catalog:"}, + "resolutions": {"left-pad@catalog:": "npm:1.3.0"}, + })) + .unwrap(); + let mut files = berry_files(berry_lock("10c0"), manifest); + files.insert( + ".yarnrc.yml".to_string(), + "catalog:\n left-pad: ^1.3.0\n".into(), + ); + let mut r = RewriteResult::default(); + rewrite_yarn_berry(&files, std::slice::from_ref(&ovr), &mut r); + assert!(r.files.is_empty(), "{:?}", r.files); + assert_eq!( + r.warnings + .iter() + .map(|w| w.code.as_str()) + .collect::>(), + ["redirect_yarn_berry_resolutions_conflict"] + ); + } + /// The selectors are descriptor-specific: another locked version of the /// same package keeps its registry entry, a multi-range key gets one /// selector per range, and the re-keyed entry moves to where yarn sorts diff --git a/docs/ecosystems.md b/docs/ecosystems.md index 1beb7b91d..61ed68964 100644 --- a/docs/ecosystems.md +++ b/docs/ecosystems.md @@ -78,7 +78,10 @@ The backticked slug in each row is the value `-e`/`--ecosystems` accepts (e.g. - **yarn berry** — the redirect pins the way yarn does for a root `resolutions` entry (cacheKey `10c0` / yarn 4): `package.json` routes the locked descriptor (`"left-pad@npm:^1.3.0"`) to the hosted tarball and only that `yarn.lock` entry - is re-keyed by it. Yarn then fetches it without npm registry credentials and + is re-keyed by it. A dependency declared through a yarn catalog (`"catalog:"`, + yarn 4.10+) is matched before yarn expands the catalog, so each `.yarnrc.yml` + catalog that resolves to the locked range is routed too (`"left-pad@catalog:"`, + `"left-pad@catalog:"`). Yarn then fetches it without npm registry credentials and hardened mode accepts it. A user-authored `resolutions` entry for the package is never overwritten (`redirect_yarn_berry_resolutions_conflict`), and `.yarnrc.yml`'s `compressionLevel` must stay 0. The node-modules linker