1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
use aube_lockfile::dep_path_filename::{
DEFAULT_VIRTUAL_STORE_DIR_MAX_LENGTH, dep_path_to_filename,
};
use aube_lockfile::graph_hash::GraphHashes;
use aube_store::Store;
use std::path::{Path, PathBuf};
use crate::{
HoistingLimits, LinkStrategy, Linker, NodeLinker, Patches, default_linker_parallelism,
};
impl Linker {
pub fn new(store: &Store, strategy: LinkStrategy) -> Self {
Self::new_with_gvs(store, strategy, !aube_util::env::is_ci())
}
pub(crate) fn new_with_gvs(
store: &Store,
strategy: LinkStrategy,
use_global_virtual_store: bool,
) -> Self {
Self {
virtual_store: store.virtual_store_dir(),
store: store.clone(),
use_global_virtual_store,
strategy,
patches: Patches::new(),
hashes: None,
virtual_store_dir_max_length: DEFAULT_VIRTUAL_STORE_DIR_MAX_LENGTH,
shamefully_hoist: false,
public_hoist_patterns: Vec::new(),
public_hoist_negations: Vec::new(),
hoist: true,
hoist_patterns: vec![glob::Pattern::new("*").expect("'*' is a valid glob pattern")],
hoist_negations: Vec::new(),
hoist_workspace_packages: true,
hoisting_limits: HoistingLimits::None,
dedupe_direct_deps: false,
node_linker: NodeLinker::Isolated,
link_concurrency: None,
virtual_store_only: false,
modules_dir_name: "node_modules".to_string(),
aube_dir_override: None,
}
}
/// Select the layout mode. Defaults to `NodeLinker::Isolated`
/// (pnpm's `.aube/`-backed virtual-store layout); `Hoisted`
/// dispatches `link_all` / `link_workspace` to the flat
/// node_modules materializer in `crate::hoisted`.
pub fn with_node_linker(mut self, node_linker: NodeLinker) -> Self {
self.node_linker = node_linker;
self
}
/// Current layout mode. The install driver reads this after
/// linking to decide how to resolve per-package directories for
/// bin linking and lifecycle scripts — isolated uses the
/// `.aube/<dep_path>` convention, hoisted consults the
/// `HoistedPlacements` returned on `LinkStats`.
pub fn node_linker(&self) -> NodeLinker {
self.node_linker
}
/// Override the name of the project-level `node_modules` directory
/// (pnpm's `modules-dir` setting). Empty strings are coerced back
/// to the default so a `.npmrc` typo can't make the linker write
/// into the project root itself. The setting only affects the
/// outer directory name — the inner virtual-store layout still
/// uses the literal `node_modules` that Node's resolver expects
/// when walking up from inside a package.
pub fn with_modules_dir_name(mut self, name: impl Into<String>) -> Self {
let s = name.into();
self.modules_dir_name = if s.trim().is_empty() {
"node_modules".to_string()
} else {
s
};
self
}
/// Project-level modules directory name. `aube` reads this
/// when it needs the same path the linker writes into — keeping
/// the computation DRY with whatever the linker was built with.
pub fn modules_dir_name(&self) -> &str {
&self.modules_dir_name
}
/// Override the per-project virtual-store path (pnpm's
/// `virtualStoreDir`). The supplied path should be *absolute* —
/// `aube` resolves relative `.npmrc` / `pnpm-workspace.yaml`
/// values against the project dir before handing them here.
/// When not set, the linker derives the virtual store path as
/// `<project_dir>/<modules_dir_name>/.aube` at link time, which
/// matches the historical behavior.
pub fn with_aube_dir_override(mut self, path: PathBuf) -> Self {
self.aube_dir_override = Some(path);
self
}
/// Compute the effective virtual-store path for `project_dir`.
/// Consults the override installed by `with_aube_dir_override` if
/// any; otherwise falls back to `<project_dir>/<modules_dir>/.<name>`.
/// Used internally by `link_all`; also called by the install
/// driver's "already linked" fast path so both sites land on the
/// same directory when the user has overridden `virtualStoreDir`.
pub fn aube_dir_for(&self, project_dir: &Path) -> PathBuf {
self.aube_dir_override.clone().unwrap_or_else(|| {
// Virtual-store leaf from the active embedder's name: `.<name>`.
// Standalone aube → `.aube`.
let leaf = format!(".{}", aube_util::embedder().name);
project_dir.join(&self.modules_dir_name).join(leaf)
})
}
/// Override the package-level linker worker count. Values below 1
/// are ignored by the install driver before they reach this point.
pub fn with_link_concurrency(mut self, concurrency: Option<usize>) -> Self {
self.link_concurrency = concurrency;
self
}
/// Override the global-virtual-store toggle set by `Linker::new`
/// (which looks at `CI`). Callers use this to force per-project
/// materialization when they've detected a consumer that breaks on
/// directory symlinks escaping the project root — e.g. Next.js /
/// Turbopack, which canonicalizes `node_modules/<pkg>` and rejects
/// anything that lands outside its declared filesystem root.
pub fn with_use_global_virtual_store(mut self, enabled: bool) -> Self {
self.use_global_virtual_store = enabled;
self
}
pub(crate) fn link_parallelism(&self) -> usize {
self.link_concurrency
.unwrap_or_else(default_linker_parallelism)
.max(1)
}
/// Enable pnpm's `shamefully-hoist` mode. When true, every package
/// in the graph gets a top-level `node_modules/<name>` symlink in
/// addition to the direct-dep entries, producing npm's flat
/// layout at the cost of phantom-dep correctness. First-write-wins
/// on duplicate names, so root deps always take precedence.
pub fn with_shamefully_hoist(mut self, shamefully_hoist: bool) -> Self {
self.shamefully_hoist = shamefully_hoist;
self
}
/// Configure pnpm's `public-hoist-pattern`. Each input is a glob
/// matched against package names; a leading `!` flips it into a
/// negation. After the usual direct-dep symlinks, every non-local
/// package whose name matches at least one positive pattern and
/// no negation gets a top-level `node_modules/<name>` symlink.
/// Invalid patterns are silently dropped (same tolerance as
/// pnpm), so a typo in `.npmrc` degrades to "not hoisted" instead
/// of failing the install.
pub fn with_public_hoist_pattern(mut self, patterns: &[String]) -> Self {
push_glob_patterns(
patterns,
&mut self.public_hoist_patterns,
&mut self.public_hoist_negations,
);
self
}
/// Toggle pnpm's `hoist` setting. When true (the default), the
/// hidden modules tree at `node_modules/.aube/node_modules/` is
/// populated via `with_hoist_pattern`. When false, that tree is
/// skipped and any existing directory is swept so stale symlinks
/// from a previous `hoist=true` run don't keep resolving.
pub fn with_hoist(mut self, hoist: bool) -> Self {
self.hoist = hoist;
self
}
/// Configure pnpm's `hoist-pattern`. Each input is a glob matched
/// against package names; a leading `!` flips it into a negation.
/// Every non-local package in the graph whose name matches at
/// least one positive pattern (and no negation) gets a
/// `node_modules/.aube/node_modules/<name>` symlink — the hidden
/// fallback dir for Node's parent-directory walk. Invalid
/// patterns are silently dropped (pnpm parity). Supplying an
/// empty list or only-negation list means "hoist nothing";
/// leaving this unconfigured keeps the default `*` match.
pub fn with_hoist_pattern(mut self, patterns: &[String]) -> Self {
self.hoist_patterns.clear();
self.hoist_negations.clear();
push_glob_patterns(
patterns,
&mut self.hoist_patterns,
&mut self.hoist_negations,
);
self
}
/// Toggle pnpm's `hoist-workspace-packages`. When false, the
/// linker skips creating `node_modules/<ws-pkg>` symlinks for
/// workspace packages in every importer, including the root.
/// Cross-importer `workspace:` deps already resolve through the
/// lockfile, so only direct `require('<ws-pkg>')` from a package
/// that doesn't declare it stops working. Default true (pnpm
/// parity).
pub fn with_hoist_workspace_packages(mut self, on: bool) -> Self {
self.hoist_workspace_packages = on;
self
}
/// Configure pnpm's `hoistingLimits` for `node-linker=hoisted`.
/// No-op for the default isolated linker.
pub fn with_hoisting_limits(mut self, limits: HoistingLimits) -> Self {
self.hoisting_limits = limits;
self
}
/// Toggle pnpm's `dedupe-direct-deps`. When true, the linker
/// skips creating a per-importer `node_modules/<name>` symlink for
/// any direct dep whose root importer already declares the same
/// package at the same resolved version — Node's parent-directory
/// walk from inside the workspace package still resolves the same
/// copy via the root-level symlink, so consumer code is
/// unaffected. Default false (pnpm parity). No-op under
/// `virtualStoreOnly=true` (no per-importer symlink pass runs)
/// and under `NodeLinker::Hoisted` (each importer gets an
/// independent flat tree — no shared root to dedupe against).
pub fn with_dedupe_direct_deps(mut self, on: bool) -> Self {
self.dedupe_direct_deps = on;
self
}
/// Whether `pkg_name` should be symlinked into the hidden hoist
/// tree. Returns false when `hoist == false` regardless of
/// patterns, or when no positive pattern matches. Matching is
/// case-insensitive, matching pnpm.
pub(crate) fn hoist_matches(&self, pkg_name: &str) -> bool {
self.hoist && matches_with_negations(pkg_name, &self.hoist_patterns, &self.hoist_negations)
}
/// Whether `pkg_name` should be promoted to the root
/// `node_modules` under the configured `public-hoist-pattern`.
/// Names with no positive match are rejected; a name that
/// matches a positive pattern is still rejected if any negation
/// also matches. Matching is case-insensitive.
pub(crate) fn public_hoist_matches(&self, pkg_name: &str) -> bool {
matches_with_negations(
pkg_name,
&self.public_hoist_patterns,
&self.public_hoist_negations,
)
}
/// Override the virtual-store directory name length cap. Primarily
/// a hook for tests and for parity with pnpm's
/// `virtual-store-dir-max-length` config; most callers should
/// leave it at the default.
pub fn with_virtual_store_dir_max_length(mut self, max_length: usize) -> Self {
self.virtual_store_dir_max_length = max_length;
self
}
/// Toggle pnpm's `virtual-store-only`. When enabled, `link_all` /
/// `link_workspace` still populate `.aube/<dep_path>/node_modules`
/// (and the shared global virtual store under
/// `~/.cache/aube/virtual-store/`) but skip the pass that writes
/// top-level `node_modules/<name>` symlinks and the hoisting
/// passes that target the same directory. No-op under
/// `NodeLinker::Hoisted` — that layout is inherently a flat
/// top-level materialization.
pub fn with_virtual_store_only(mut self, only: bool) -> Self {
self.virtual_store_only = only;
self
}
/// Whether this linker will skip the top-level `node_modules/<name>`
/// symlink pass. Exposed so the install driver can omit root-level
/// bin linking and lifecycle-script invocations when the user has
/// asked for a virtual-store-only install — both operate on the
/// top-level tree that won't exist.
pub fn virtual_store_only(&self) -> bool {
self.virtual_store_only
}
/// Install a set of pre-computed graph hashes. Every virtual-store
/// path the linker constructs after this point will use the
/// hashed subdir name for the matching `dep_path`. Callers
/// normally derive the hashes once per install via
/// `aube_lockfile::graph_hash::compute_graph_hashes` and pass the
/// result in here.
pub fn with_graph_hashes(mut self, hashes: GraphHashes) -> Self {
self.hashes = Some(hashes);
self
}
/// Directory name for `dep_path` inside the global virtual store.
/// Applies the graph hash (if any) to fold in build state, then
/// runs the result through `dep_path_to_filename` so the final
/// name is both filesystem-safe and bounded.
pub(crate) fn virtual_store_subdir(&self, dep_path: &str) -> String {
let hashed = match &self.hashes {
Some(h) => h.hashed_dep_path(dep_path),
None => dep_path.to_string(),
};
dep_path_to_filename(&hashed, self.virtual_store_dir_max_length)
}
/// Directory name for `dep_path` inside a project's local
/// `node_modules/.aube/`. Same filename-bounding as the global
/// store, but without the graph-hash fold — local `.aube/` is
/// keyed by dep_path alone because node's resolver walks by
/// dep_path and never inspects the shared-store identity.
pub(crate) fn aube_dir_entry_name(&self, dep_path: &str) -> String {
dep_path_to_filename(dep_path, self.virtual_store_dir_max_length)
}
/// Whether this linker populates the project's `.aube/` entries as
/// symlinks into the shared virtual store (true) or materializes a
/// per-project copy (false). Callers that want to mutate package
/// directories after linking — e.g. running allowBuilds lifecycle
/// scripts — need to know because shared-store writes leak across
/// projects.
pub fn uses_global_virtual_store(&self) -> bool {
self.use_global_virtual_store
}
/// Install a set of patch contents to apply at materialize time.
/// Replaces any previously installed patches. Pair with
/// `with_graph_hashes` whose `patch_hash` callback returns the same
/// per-`(name, version)` digest, so the patched bytes land at a
/// distinct virtual-store path from the unpatched ones.
pub fn with_patches(mut self, patches: Patches) -> Self {
self.patches = patches;
self
}
}
fn push_glob_patterns(
raw: &[String],
positives: &mut Vec<glob::Pattern>,
negations: &mut Vec<glob::Pattern>,
) {
for r in raw {
let (neg, body) = match r.strip_prefix('!') {
Some(rest) => (true, rest),
None => (false, r.as_str()),
};
let Ok(pat) = glob::Pattern::new(body) else {
continue;
};
if neg {
negations.push(pat);
} else {
positives.push(pat);
}
}
}
fn matches_with_negations(
name: &str,
positives: &[glob::Pattern],
negations: &[glob::Pattern],
) -> bool {
if positives.is_empty() {
return false;
}
let opts = glob::MatchOptions {
case_sensitive: false,
require_literal_separator: false,
require_literal_leading_dot: false,
};
positives.iter().any(|p| p.matches_with(name, opts))
&& !negations.iter().any(|p| p.matches_with(name, opts))
}