@vltpkg/graph 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -93,13 +93,14 @@ code is not loaded. `update` and `uninstall` take `allowSkills` too.
93
93
 
94
94
  Lists the agent skills (dirs with a `SKILL.md`, see
95
95
  https://agentskills.io/specification) shipped by installed packages in
96
- the vlt store (a root `SKILL.md`, then `<dir>/SKILL.md` in `skills/`,
97
- `dist/skills/` and `.agents/skills/`; the first skill of a name wins),
98
- from one version per package (a direct dep, else the highest), with
99
- the agent skill dirs each is linked in, `project`: the main importer
100
- name, and the `graph`. `options.target` is a DSS query selecting
101
- packages (default `*`); a selector ending with `:skill(<name>, …)`
102
- keeps only those skills of its packages. Creates nothing.
96
+ the vlt store and the project's workspaces (a root `SKILL.md`, then
97
+ `<dir>/SKILL.md` in `skills/`, `dist/skills/` and `.agents/skills/`;
98
+ the first skill of a name wins), from one version per package (a
99
+ direct dep or workspace, else the highest), with the agent skill dirs
100
+ each is linked in, `project`: the main importer name, and the `graph`.
101
+ `options.target` is a DSS query selecting packages (default `*`); a
102
+ selector ending with `:skill(<name>, …)` keeps only those skills of
103
+ its packages. Creates nothing.
103
104
 
104
105
  ### `linkSkills(options): Promise<LinkSkillsResult>`
105
106
 
@@ -112,21 +113,23 @@ must resolve inside the project, not in `node_modules` (else
112
113
  `skipped`, as are fs errors) and are created only when a skill is
113
114
  picked. One package per name, among picked skills, in every dir: the
114
115
  one the first existing link points to while its skill is installed,
115
- else a direct dep, then DepID; the others are reported in `conflicts`
116
- as `<mount> (<package>)`. Installs keep a dir's link to another
117
- installed skill. A link follows its package to the picked version.
118
- Then removes links whose skill is no longer installed: dangling,
119
- package not in the graph, or no longer shipped. With `options.apply`,
120
- also removes every other vlt link in the scanned dirs. Only links
121
- pointing into a `node_modules/.vlt` store are ever replaced or
122
- removed; anything else in the way is reported in `conflicts`. Keeps a
123
- marked block of link names in each dir's `.gitignore` (`gitignore`
124
- lists the ones it could not edit). Linked packages get private copies
125
- of files hardlinked from the global store. `:skill()` patterns that
126
- match no skill of their packages are returned in `unmatched`. Security
127
- queries fail closed: skills of npm registry packages with no security
128
- data are left out (in `listSkills` too) and returned in `unscanned`
129
- with `unscannedReason` (by `linkSkills`: unless still linked).
116
+ else a direct dep or workspace, then DepID; the others are reported in
117
+ `conflicts` as `<mount> (<package>)`. Installs keep a dir's link to
118
+ another installed skill. A link follows its package to the picked
119
+ version. Then removes links whose skill is no longer installed:
120
+ dangling, package not in the graph, or no longer shipped. With
121
+ `options.apply`, also removes every other vlt link in the scanned
122
+ dirs. Only vlt links (into a `node_modules/.vlt` store, or listed in
123
+ the dir's `.gitignore` block and pointing to a workspace or dangling)
124
+ are ever replaced or removed; anything else in the way is reported in
125
+ `conflicts`. Keeps a marked block of link names in each dir's
126
+ `.gitignore` (`gitignore` lists the ones it could not edit). Linked
127
+ packages get private copies of files hardlinked from the global store.
128
+ `:skill()` patterns that match no skill of their packages are returned
129
+ in `unmatched`. Security queries fail closed: skills of npm registry
130
+ packages with no security data are left out (in `listSkills` too) and
131
+ returned in `unscanned` with `unscannedReason` (by `linkSkills`:
132
+ unless still linked).
130
133
 
131
134
  ### `assertQuery(selector, flag, partSelectors?): Promise<void>`
132
135
 
package/dist/graph.js CHANGED
@@ -347,6 +347,9 @@ export class Graph {
347
347
  }
348
348
  return edge;
349
349
  }
350
+ // a stale edgesIn ref would let removing the old target drop
351
+ // the new edge from `from.edgesOut`
352
+ edge.to?.edgesIn.delete(edge);
350
353
  this.edges.delete(edge);
351
354
  }
352
355
  this.mutations++;
@@ -419,7 +419,7 @@ const fetchManifestsForDeps = async (packageInfo, graph, fromNode, deps, scurry,
419
419
  * vlt store are extracted immediately (in parallel) instead of waiting for
420
420
  * the full ideal graph to be built. This significantly improves install time.
421
421
  */
422
- const processPlacementTasks = async (graph, options, placementTasks, add, modifiers, scurry, packageInfo, extractPromises, actual, seenExtracted, remover, transientAdd, transientRemove) => {
422
+ const processPlacementTasks = async (graph, options, placementTasks, add, modifiers, scurry, packageInfo, extractPromises, actual, seenExtracted, remover, transientAdd, transientRemove, explicit) => {
423
423
  const childDepsToProcess = [];
424
424
  // Note: placementTasks are already sorted by fetchManifestsForDeps
425
425
  // using compareByHasPeerDeps to ensure non-peer deps are processed first.
@@ -431,18 +431,31 @@ const processPlacementTasks = async (graph, options, placementTasks, add, modifi
431
431
  // reify step to properly update the package.json file dependencies
432
432
  // using the correct names retrieved from the manifest data
433
433
  const additiveMap = fromNode.importer ? add : transientAdd?.get(fromNode.id);
434
+ // a nameless explicit add is known by its fixed up name from here on
435
+ const explicitKeys = explicit?.get(fromNode.id);
436
+ const wasExplicit = explicitKeys?.has(addKey(spec));
434
437
  spec = fixupAddedNames(additiveMap, manifest, options, spec);
438
+ if (wasExplicit)
439
+ explicitKeys?.add(spec.name);
435
440
  // handles missing manifest resolution
436
441
  if (!manifest) {
437
442
  if (!edgeOptional && fromNode.isOptional()) {
438
443
  // failed resolution of a non-optional dep of an optional node
439
444
  // have to clean up the dependents
440
- removeOptionalSubgraph(graph, fromNode);
445
+ for (const n of removeOptionalSubgraph(graph, fromNode)) {
446
+ // pruned importer deps: nothing to save, unless requested
447
+ for (const { from, name } of n.edgesIn) {
448
+ if (from.importer && !explicit?.get(from.id)?.has(name)) {
449
+ add?.delete(name);
450
+ }
451
+ }
452
+ }
441
453
  continue;
442
454
  }
443
455
  else if (edgeOptional) {
444
- // failed resolution of an optional dep, just ignore it,
445
- // nothing to prune because we never added it in the first place.
456
+ // failed resolution of an optional dep: skip it and drop it
457
+ // from the add map, nothing to prune or save
458
+ additiveMap?.delete(addKey(spec));
446
459
  continue;
447
460
  }
448
461
  else {
@@ -738,7 +751,7 @@ export const appendNodes = async (packageInfo, graph, fromNode, deps, scurry, op
738
751
  graph.addEdge(type, spec, fromNode, toNode);
739
752
  }
740
753
  // Place nodes and collect child deps
741
- const placed = await processPlacementTasks(graph, options, result.placementTasks, add, modifiers, scurry, packageInfo, extractPromises, actual, seenExtracted, remover, transientAdd, transientRemove);
754
+ const placed = await processPlacementTasks(graph, options, result.placementTasks, add, modifiers, scurry, packageInfo, extractPromises, actual, seenExtracted, remover, transientAdd, transientRemove, explicit);
742
755
  levelResults.push(placed);
743
756
  }
744
757
  // ============================================================
@@ -31,18 +31,26 @@ export const removeSatisfiedSpecs = ({ add, graph, modifiers, }) => {
31
31
  const depIsCatalog = dependency.spec.type === 'catalog';
32
32
  if (edgeIsCatalog !== depIsCatalog)
33
33
  continue;
34
+ // a governed edge is checked against the modifier value (e.g.
35
+ // `workspace:*` to a versionless workspace), not its own spec: one
36
+ // built before the modifier applied must be rebuilt
37
+ const mod = modifiers?.importerEdgeModifier(importer, dependency.spec);
34
38
  // If the current graph edge is already valid, then we remove that
35
39
  // dependency item from the list of items to be added to the graph
36
- if (satisfies(edge.to?.id, dependency.spec, edge.from.location, graph.projectRoot, graph.monorepo)) {
37
- // a governed edge carries the modifier value by construction:
38
- // healing it to the manifest text would only be undone by the
39
- // rebuild that re-applies the override. `implicit` is not a real
40
- // edge type, it means "keep what the edge already is", so it
41
- // never makes an edge stale
42
- if ((edge.spec.bareSpec !== dependency.spec.bareSpec ||
43
- (dependency.type !== 'implicit' &&
44
- edge.type !== dependency.type)) &&
45
- !modifiers?.targetsImporterEdge(importer, dependency.spec)) {
40
+ if (satisfies(edge.to?.id, mod?.type === 'edge' ? mod.spec : dependency.spec, edge.from.location, graph.projectRoot, graph.monorepo)) {
41
+ // `implicit` is not a real edge type, it means "keep what the
42
+ // edge already is", so it never makes an edge stale
43
+ const typeChanged = dependency.type !== 'implicit' &&
44
+ edge.type !== dependency.type;
45
+ if (mod) {
46
+ // keeps the modifier value, the rebuild would re-apply it
47
+ // anyway; only the type follows the manifest
48
+ if (typeChanged) {
49
+ staleSpecs.set(edge, { ...dependency, spec: edge.spec });
50
+ }
51
+ }
52
+ else if (edge.spec.bareSpec !== dependency.spec.bareSpec ||
53
+ typeChanged) {
46
54
  staleSpecs.set(edge, dependency);
47
55
  }
48
56
  dependencies.delete(name);
package/dist/install.d.ts CHANGED
@@ -22,6 +22,8 @@ export type InstallOptions = LoadOptions & {
22
22
  * none
23
23
  */
24
24
  skillsDirs?: string[];
25
+ /** store dirs of these nodes are never removed, eg the running vlt */
26
+ keepNodes?: ReadonlySet<DepID>;
25
27
  };
26
28
  /**
27
29
  * Result of {@link install}.
package/dist/install.js CHANGED
@@ -239,6 +239,7 @@ export const install = async (options, add) => {
239
239
  ...options,
240
240
  add,
241
241
  graph,
242
+ modifiers,
242
243
  remove,
243
244
  })
244
245
  : undefined;
@@ -101,6 +101,11 @@ export type ModifierActiveEntry = {
101
101
  * for (const entry of entries)
102
102
  * modifier.updateActiveEntry(fromNode, entry)
103
103
  * ```
104
+ *
105
+ * A trailing `:workspace` right after an importer selector, e.g.
106
+ * `:project > :workspace`, is expanded into one `#<name>` entry per
107
+ * named workspace of the graph, on its first `tryImporter` or
108
+ * `importerEdgeModifier` call.
104
109
  */
105
110
  export declare class GraphModifier {
106
111
  #private;
@@ -126,6 +131,12 @@ export declare class GraphModifier {
126
131
  * root's own b edge is still the manifest's to validate and heal.
127
132
  */
128
133
  targetsImporterEdge(importer: Node, spec: Spec): boolean;
134
+ /**
135
+ * The modifier governing the direct `importer -> spec.name` edge, see
136
+ * {@link GraphModifier.targetsImporterEdge}. The most specific one
137
+ * wins, same as during the graph build.
138
+ */
139
+ importerEdgeModifier(importer: Node, spec: Spec): ModifierEntry | undefined;
129
140
  /**
130
141
  * Selector of the modifier that wins the direct `importer -> spec.name`
131
142
  * edge, if it removes it (value `-`). Same pick as tryNewDependency:
package/dist/modifiers.js CHANGED
@@ -7,9 +7,20 @@ import { load } from '@vltpkg/vlt-json';
7
7
  * Does this breadcrumb item select the given importer?
8
8
  */
9
9
  const matchesImporter = (item, importer) => item.importer &&
10
+ importer.importer &&
10
11
  (item.value === ':project' ||
11
- (item.value === ':root' && importer.mainImporter) ||
12
- (item.value === ':workspace' && importer.importer));
12
+ item.value === ':workspace' ||
13
+ (item.value === ':root' && importer.mainImporter));
14
+ /**
15
+ * Is this a trailing `:workspace` item right after a plain importer
16
+ * selector, e.g. `:project > :workspace`? It selects any direct
17
+ * dependency named after a workspace.
18
+ */
19
+ const isWorkspaceTarget = (item) => item.importer &&
20
+ item.value === ':workspace' &&
21
+ !!item.prev?.importer &&
22
+ !item.prev.name &&
23
+ !item.prev.prev;
13
24
  /**
14
25
  * Does this item's qualifier (e.g. `:semver(^1)`, `:v(^1)`) accept the
15
26
  * given spec? Shared by traversal matching and by the frozen / healing
@@ -42,6 +53,11 @@ const matchesQualifier = (item, spec) => !!item?.comparator({ semver: spec.semve
42
53
  * for (const entry of entries)
43
54
  * modifier.updateActiveEntry(fromNode, entry)
44
55
  * ```
56
+ *
57
+ * A trailing `:workspace` right after an importer selector, e.g.
58
+ * `:project > :workspace`, is expanded into one `#<name>` entry per
59
+ * named workspace of the graph, on its first `tryImporter` or
60
+ * `importerEdgeModifier` call.
45
61
  */
46
62
  export class GraphModifier {
47
63
  /** The loaded modifiers configuration */
@@ -73,6 +89,14 @@ export class GraphModifier {
73
89
  activeModifiers = new Set();
74
90
  /** A set of all modifier string values loaded from vlt.json */
75
91
  modifierNames = new Set();
92
+ /** Spec options used when expanding `:workspace` targets */
93
+ #options = {};
94
+ /** `<importer> > :workspace` modifiers, expanded once a graph is known */
95
+ #workspaceTargets = [];
96
+ /** Graphs whose workspaces were already expanded */
97
+ #expandedGraphs = new WeakSet();
98
+ /** Workspace names already expanded */
99
+ #expandedNames = new Set();
76
100
  constructor(options) {
77
101
  this.load(options);
78
102
  }
@@ -96,8 +120,18 @@ export class GraphModifier {
96
120
  * root's own b edge is still the manifest's to validate and heal.
97
121
  */
98
122
  targetsImporterEdge(importer, spec) {
99
- for (const { breadcrumb } of this.#modifiers) {
100
- const { last } = breadcrumb;
123
+ return !!this.importerEdgeModifier(importer, spec);
124
+ }
125
+ /**
126
+ * The modifier governing the direct `importer -> spec.name` edge, see
127
+ * {@link GraphModifier.targetsImporterEdge}. The most specific one
128
+ * wins, same as during the graph build.
129
+ */
130
+ importerEdgeModifier(importer, spec) {
131
+ this.#expandWorkspaceTargets(importer);
132
+ const found = [];
133
+ for (const mod of this.#modifiers) {
134
+ const { last } = mod.breadcrumb;
101
135
  if (last.name !== spec.name)
102
136
  continue;
103
137
  // a qualifier that rejects the spec means the modifier never
@@ -106,14 +140,12 @@ export class GraphModifier {
106
140
  continue;
107
141
  const { prev } = last;
108
142
  // a lone `#b` matches an edge to b anywhere, importers included
109
- if (!prev)
110
- return true;
111
- if (prev.prev)
112
- continue;
113
- if (matchesImporter(prev, importer))
114
- return true;
143
+ if (!prev || (!prev.prev && matchesImporter(prev, importer))) {
144
+ found.push(mod);
145
+ }
115
146
  }
116
- return false;
147
+ const [best] = specificitySort(found.map(m => m.breadcrumb));
148
+ return found.find(m => m.breadcrumb === best);
117
149
  }
118
150
  /**
119
151
  * Selector of the modifier that wins the direct `importer -> spec.name`
@@ -143,16 +175,22 @@ export class GraphModifier {
143
175
  * Loads the modifiers defined in `vlt.json` into memory.
144
176
  */
145
177
  load(options) {
178
+ this.#options = options;
146
179
  for (const [key, value] of Object.entries(this.config)) {
147
180
  this.modifierNames.add(key);
148
181
  const breadcrumb = parseBreadcrumb(key);
149
- /* c8 ignore start - should not be possible */
150
182
  if (!breadcrumb.last.name) {
151
- throw error('Could not find name in breadcrumb', {
183
+ // `<importer> > :workspace` names no package up front, it is
184
+ // expanded into one entry per workspace once a graph is known
185
+ if (isWorkspaceTarget(breadcrumb.last)) {
186
+ this.#workspaceTargets.push({ query: key, value });
187
+ continue;
188
+ }
189
+ throw error('Invalid modifier selector', {
152
190
  found: key,
191
+ wanted: 'a selector ending in #<name>, or :root, :project or :workspace followed by > :workspace',
153
192
  });
154
193
  }
155
- /* c8 ignore stop */
156
194
  let mod;
157
195
  if (typeof value === 'string') {
158
196
  mod = {
@@ -179,13 +217,58 @@ export class GraphModifier {
179
217
  this.#nodeModifiers.add(mod);
180
218
  }
181
219
  /* c8 ignore end */
182
- this.#modifiers.add(mod);
183
- // if the breadcrumb starts with an id, then add it to the
184
- // map of initial entries, so that we can use it to match
185
- if (breadcrumb.first.name) {
186
- const initialSet = this.#initialEntries.get(breadcrumb.first.name) ?? new Set();
187
- initialSet.add(mod);
188
- this.#initialEntries.set(breadcrumb.first.name, initialSet);
220
+ this.#register(mod);
221
+ }
222
+ }
223
+ /**
224
+ * Adds a modifier entry to the set of known modifiers.
225
+ */
226
+ #register(mod) {
227
+ this.#modifiers.add(mod);
228
+ // if the breadcrumb starts with an id, then add it to the
229
+ // map of initial entries, so that we can use it to match
230
+ const { name } = mod.breadcrumb.first;
231
+ if (name) {
232
+ const initialSet = this.#initialEntries.get(name) ?? new Set();
233
+ initialSet.add(mod);
234
+ this.#initialEntries.set(name, initialSet);
235
+ }
236
+ }
237
+ /**
238
+ * Expands `<importer> > :workspace` modifiers into one
239
+ * `<importer> > #<name>` entry per named workspace of the node's graph.
240
+ */
241
+ #expandWorkspaceTargets(node) {
242
+ if (!this.#workspaceTargets.length)
243
+ return;
244
+ const { graph } = node;
245
+ if (this.#expandedGraphs.has(graph))
246
+ return;
247
+ this.#expandedGraphs.add(graph);
248
+ for (const importer of graph.importers) {
249
+ // nameless workspaces are named after their folder, never link those
250
+ const name = importer.manifest?.name;
251
+ if (importer.mainImporter ||
252
+ !name ||
253
+ this.#expandedNames.has(name)) {
254
+ continue;
255
+ }
256
+ this.#expandedNames.add(name);
257
+ for (const { query, value } of this.#workspaceTargets) {
258
+ // fresh breadcrumb per name: items are a linked list and the
259
+ // active entry maps key on the last item's name
260
+ const breadcrumb = parseBreadcrumb(query);
261
+ breadcrumb.last.name = name;
262
+ const mod = {
263
+ breadcrumb,
264
+ query,
265
+ refs: new Set(),
266
+ spec: Spec.parse(name, value, this.#options),
267
+ type: 'edge',
268
+ value,
269
+ };
270
+ this.#edgeModifiers.add(mod);
271
+ this.#register(mod);
189
272
  }
190
273
  }
191
274
  }
@@ -195,6 +278,7 @@ export class GraphModifier {
195
278
  * update the active entry to the current importer node.
196
279
  */
197
280
  tryImporter(importer) {
281
+ this.#expandWorkspaceTargets(importer);
198
282
  for (const modifier of this.#modifiers) {
199
283
  // if the first item in the breadcrumb is an importer and it matches
200
284
  // any of the valid top-level selectors, then register the modifier
@@ -7,3 +7,7 @@ import type { Edge } from '../edge.ts';
7
7
  * until scripts are run, in the case of non-store deps like workspaces
8
8
  */
9
9
  export declare const addEdge: (edge: Edge, scurry: PathScurry, remover: RollbackRemove, bins?: Record<string, string>) => Promise<void>;
10
+ /**
11
+ * link the `key` bin of the package at `path` into `binRoot`
12
+ */
13
+ export declare const linkBin: (path: string, binRoot: string, key: string, val: string, remover: RollbackRemove) => Promise<void>;
@@ -59,15 +59,21 @@ export const addEdge = async (edge, scurry, remover, bins) => {
59
59
  promises.push(p);
60
60
  if (bins) {
61
61
  for (const [key, val] of Object.entries(bins)) {
62
- const link = resolve(binRoot, key);
63
- const absTarget = resolve(path, val);
64
- const target = relative(binRoot, absTarget);
65
- // TODO: bash/cmd/ps1 shims on Windows
66
- promises.push(process.platform === 'win32' ?
67
- cmdShimIfExists(absTarget, link, remover)
68
- : clobberSymlink(target, link, remover));
62
+ promises.push(linkBin(path, binRoot, key, val, remover));
69
63
  }
70
64
  }
71
65
  if (promises.length)
72
66
  await Promise.all(promises);
73
67
  };
68
+ /**
69
+ * link the `key` bin of the package at `path` into `binRoot`
70
+ */
71
+ export const linkBin = (path, binRoot, key, val, remover) => {
72
+ const link = resolve(binRoot, key);
73
+ const absTarget = resolve(path, val);
74
+ const target = relative(binRoot, absTarget);
75
+ // TODO: bash/cmd/ps1 shims on Windows
76
+ return process.platform === 'win32' ?
77
+ cmdShimIfExists(absTarget, link, remover)
78
+ : clobberSymlink(target, link, remover);
79
+ };
@@ -1,15 +1,12 @@
1
+ import { readCmdShimIfExists } from '@vltpkg/cmd-shim';
1
2
  import { resolve } from 'node:path';
2
- const rmBinPosix = (remover, bin) => {
3
- return [remover.rm(bin)];
4
- };
5
- const rmBinWin32 = (remover, bin) => {
6
- return [
7
- remover.rm(bin),
8
- remover.rm(bin + '.cmd'),
9
- remover.rm(bin + '.ps1'),
10
- ];
11
- };
12
- const rmBin = process.platform === 'win32' ? rmBinWin32 : rmBinPosix;
3
+ const binPathsPosix = (bin) => [bin];
4
+ const binPathsWin32 = (bin) => [
5
+ bin,
6
+ bin + '.cmd',
7
+ bin + '.ps1',
8
+ ];
9
+ const binPaths = process.platform === 'win32' ? binPathsWin32 : binPathsPosix;
13
10
  export const deleteEdge = async (edge, scurry, remover) => {
14
11
  const { spec: { name }, to, } = edge;
15
12
  const { sep } = scurry.cwd;
@@ -20,9 +17,14 @@ export const deleteEdge = async (edge, scurry, remover) => {
20
17
  promises.push(remover.rm(path));
21
18
  const bins = to?.bins;
22
19
  if (bins) {
23
- for (const key of Object.keys(bins)) {
24
- const bin = resolve(binRoot, key);
25
- promises.push(...rmBin(remover, bin));
20
+ for (const [key, value] of Object.entries(bins)) {
21
+ for (const bin of binPaths(resolve(binRoot, key))) {
22
+ const target = await readCmdShimIfExists(bin);
23
+ // another dependency may provide the same command.
24
+ if (target && target !== resolve(path, value))
25
+ continue;
26
+ promises.push(remover.rm(bin));
27
+ }
26
28
  }
27
29
  }
28
30
  await Promise.all(promises);
@@ -1,4 +1,5 @@
1
+ import type { DepID } from '@vltpkg/dep-id';
1
2
  import type { RollbackRemove } from '@vltpkg/rollback-remove';
2
3
  import type { PathScurry } from 'path-scurry';
3
4
  import type { Diff } from '../diff.ts';
4
- export declare const deleteNodes: (diff: Diff, remover: RollbackRemove, scurry: PathScurry) => Promise<unknown>[];
5
+ export declare const deleteNodes: (diff: Diff, remover: RollbackRemove, scurry: PathScurry, keep?: ReadonlySet<DepID>) => Promise<unknown>[];
@@ -1,5 +1,5 @@
1
1
  import { deleteEdge } from "./delete-edge.js";
2
- export const deleteNodes = (diff, remover, scurry) => {
2
+ export const deleteNodes = (diff, remover, scurry, keep) => {
3
3
  const store = scurry.resolve('node_modules/.vlt');
4
4
  const { sep } = scurry.cwd;
5
5
  const rmActions = [];
@@ -7,7 +7,9 @@ export const deleteNodes = (diff, remover, scurry) => {
7
7
  // do not delete workspaces or link targets
8
8
  if (!node.inVltStore())
9
9
  continue;
10
- rmActions.push(remover.rm(store + sep + node.id));
10
+ if (!keep?.has(node.id)) {
11
+ rmActions.push(remover.rm(store + sep + node.id));
12
+ }
11
13
  for (const edge of node.edgesIn) {
12
14
  rmActions.push(deleteEdge(edge, scurry, remover));
13
15
  }
@@ -18,6 +18,8 @@ export type ReifyOptions = LoadOptions & {
18
18
  update?: boolean;
19
19
  saveExact?: boolean;
20
20
  savePrefix?: string;
21
+ /** store dirs of these nodes are never removed, eg the running vlt */
22
+ keepNodes?: ReadonlySet<DepID>;
21
23
  };
22
24
  export type ReifyResult = {
23
25
  /**
@@ -8,6 +8,7 @@ import { addEdges } from "./add-edges.js";
8
8
  import { addNodes } from "./add-nodes.js";
9
9
  import { build } from "./build.js";
10
10
  import { deleteEdges } from "./delete-edges.js";
11
+ import { relinkBins } from "./relink-bins.js";
11
12
  import { carryBuildState, checkNeededBuild, } from "./check-needed-build.js";
12
13
  import { deleteNodes } from "./delete-nodes.js";
13
14
  import { internalHoist } from "./internal-hoist.js";
@@ -47,6 +48,7 @@ export const reify = async (options) => {
47
48
  packageJson: options.packageJson,
48
49
  saveExact: options.saveExact,
49
50
  savePrefix: options.savePrefix,
51
+ modifiers: options.modifiers,
50
52
  })
51
53
  : undefined;
52
54
  const noModifiedDependencies = !options.add?.modifiedDependencies &&
@@ -127,6 +129,10 @@ const reify_ = async (options, diff, remover, saveImportersPackageJson) => {
127
129
  const edgeActions = addEdges(diff, scurry, remover);
128
130
  if (edgeActions.length)
129
131
  await callLimit(edgeActions, { limit });
132
+ // link bins that deleted edges removed but other deps still declare
133
+ const binActions = relinkBins(diff, scurry, remover);
134
+ if (binActions.length)
135
+ await callLimit(binActions, { limit });
130
136
  await internalHoist(diff.to, options, remover);
131
137
  // looks up all nodes setting buildState = 'needed'
132
138
  // on nodes that require building
@@ -155,7 +161,7 @@ const reify_ = async (options, diff, remover, saveImportersPackageJson) => {
155
161
  /* c8 ignore stop */
156
162
  saveHidden(options);
157
163
  // delete garbage from the store.
158
- const rmActions = deleteNodes(diff, remover, scurry);
164
+ const rmActions = deleteNodes(diff, remover, scurry, options.keepNodes);
159
165
  if (rmActions.length)
160
166
  await Promise.all(rmActions);
161
167
  // updates package.json files if anything was added / removed
@@ -0,0 +1,9 @@
1
+ import type { RollbackRemove } from '@vltpkg/rollback-remove';
2
+ import type { PathScurry } from 'path-scurry';
3
+ import type { Diff } from '../diff.ts';
4
+ /**
5
+ * A deleted edge removes the bins it owns, but another dependency
6
+ * that did not change may declare the same bin name. Link the first
7
+ * one found if the bin is still missing once added edges are linked.
8
+ */
9
+ export declare const relinkBins: (diff: Diff, scurry: PathScurry, remover: RollbackRemove) => (() => Promise<unknown>)[];
@@ -0,0 +1,39 @@
1
+ import { resolve } from 'node:path';
2
+ import { linkBin } from "./add-edge.js";
3
+ /**
4
+ * A deleted edge removes the bins it owns, but another dependency
5
+ * that did not change may declare the same bin name. Link the first
6
+ * one found if the bin is still missing once added edges are linked.
7
+ */
8
+ export const relinkBins = (diff, scurry, remover) => {
9
+ const actions = [];
10
+ const seen = new Set();
11
+ const { sep } = scurry.cwd;
12
+ for (const deleted of diff.edges.delete) {
13
+ const node = diff.to.nodes.get(deleted.from.id);
14
+ const bins = deleted.to?.bins;
15
+ if (!node || !bins)
16
+ continue;
17
+ const nm = node.nodeModules(scurry);
18
+ const binRoot = nm + sep + '.bin';
19
+ for (const key of Object.keys(bins)) {
20
+ const link = resolve(binRoot, key);
21
+ if (seen.has(link))
22
+ continue;
23
+ seen.add(link);
24
+ for (const edge of node.edgesOut.values()) {
25
+ const val = edge.to?.bins?.[key];
26
+ if (!val)
27
+ continue;
28
+ const path = nm + sep + edge.spec.name.replace('/', sep);
29
+ actions.push(async () => {
30
+ if (await scurry.lstat(link))
31
+ return;
32
+ await linkBin(path, binRoot, key, val, remover);
33
+ });
34
+ break;
35
+ }
36
+ }
37
+ }
38
+ return actions;
39
+ };
@@ -1,6 +1,7 @@
1
1
  import type { PackageJson } from '@vltpkg/package-json';
2
2
  import type { AddImportersDependenciesMap, RemoveImportersDependenciesMap } from '../dependencies.ts';
3
3
  import type { Graph } from '../graph.ts';
4
+ import type { GraphModifier } from '../modifiers.ts';
4
5
  export type UpdatePackageJsonOptions = {
5
6
  /**
6
7
  * A `Map` in which keys are {@link DepID} linking to another `Map` in which
@@ -37,9 +38,13 @@ export type UpdatePackageJsonOptions = {
37
38
  * Defaults to `^` if not specified. Ignored when `saveExact` is true.
38
39
  */
39
40
  savePrefix?: string;
41
+ /**
42
+ * Graph modifiers, importer edges they govern keep the modifier value.
43
+ */
44
+ modifiers?: GraphModifier;
40
45
  };
41
46
  /**
42
47
  * Updates nodes of a provided {@link Graph} accordingly to the
43
48
  * provided add or remove arguments.
44
49
  */
45
- export declare const updatePackageJson: ({ add, graph, packageJson, remove, saveExact, savePrefix, }: UpdatePackageJsonOptions) => () => void;
50
+ export declare const updatePackageJson: ({ add, graph, packageJson, remove, saveExact, savePrefix, modifiers, }: UpdatePackageJsonOptions) => () => void;
@@ -5,7 +5,7 @@ import { longDependencyTypes } from '@vltpkg/types';
5
5
  import { longDependencyTypeNames } from "../dependencies.js";
6
6
  import { resolveSaveType } from "../resolve-save-type.js";
7
7
  import { calculateSaveValue } from "./calculate-save-value.js";
8
- const addOrRemoveDeps = (nodeId, graph, addOrRemove, saveExact, savePrefix) => {
8
+ const addOrRemoveDeps = (nodeId, graph, addOrRemove, saveExact, savePrefix, modifiers) => {
9
9
  const node = graph.nodes.get(nodeId);
10
10
  if (!node) {
11
11
  throw error('Failed to retrieve node', {
@@ -56,7 +56,10 @@ const addOrRemoveDeps = (nodeId, graph, addOrRemove, saveExact, savePrefix) => {
56
56
  }
57
57
  const edge = node.edgesOut.get(name);
58
58
  if (!edge?.to) {
59
- throw error('Dependency node could not be found');
59
+ throw error(`Dependency node could not be found: ${name}`, {
60
+ spec: dep.spec,
61
+ from: node.location,
62
+ });
60
63
  }
61
64
  const n = edge.to;
62
65
  const [nodeType] = splitDepID(n.id);
@@ -90,8 +93,10 @@ const addOrRemoveDeps = (nodeId, graph, addOrRemove, saveExact, savePrefix) => {
90
93
  // the lockfile has to carry the value package.json carries, or a
91
94
  // later install reads back `latest` where the manifest says
92
95
  // `^1.2.3`. anything keyed by the old spec from here on must be
93
- // invalidated at this point.
94
- if (saveValue !== edge.spec.bareSpec) {
96
+ // invalidated at this point. governed edges keep the modifier
97
+ // value instead.
98
+ if (saveValue !== edge.spec.bareSpec &&
99
+ !modifiers?.targetsImporterEdge(node, dep.spec)) {
95
100
  edge.spec = Spec.parse(name, saveValue, dep.spec.options);
96
101
  graph.lockfileStale = true;
97
102
  }
@@ -103,7 +108,7 @@ const addOrRemoveDeps = (nodeId, graph, addOrRemove, saveExact, savePrefix) => {
103
108
  * Updates nodes of a provided {@link Graph} accordingly to the
104
109
  * provided add or remove arguments.
105
110
  */
106
- export const updatePackageJson = ({ add, graph, packageJson, remove, saveExact, savePrefix, }) => {
111
+ export const updatePackageJson = ({ add, graph, packageJson, remove, saveExact, savePrefix, modifiers, }) => {
107
112
  const manifestsToUpdate = new Set();
108
113
  const operations = new Set([add, remove]);
109
114
  for (const operation of operations) {
@@ -112,7 +117,7 @@ export const updatePackageJson = ({ add, graph, packageJson, remove, saveExact,
112
117
  // that are nested folders from which the user can also add new
113
118
  // dependencies to
114
119
  for (const nodeId of operation.keys()) {
115
- const manifest = addOrRemoveDeps(nodeId, graph, operation, saveExact, savePrefix);
120
+ const manifest = addOrRemoveDeps(nodeId, graph, operation, saveExact, savePrefix, modifiers);
116
121
  if (manifest) {
117
122
  manifestsToUpdate.add(manifest);
118
123
  }
@@ -61,13 +61,13 @@ export declare const shipsSkills: (node: Node) => boolean;
61
61
  /**
62
62
  * Find the agent skills shipped by the given installed packages.
63
63
  *
64
- * Only packages in the vlt store are scanned, for a root `SKILL.md`,
65
- * then `<dir>/SKILL.md` in `skills/`, `dist/skills/` and
66
- * `.agents/skills/`: the first skill of a name wins. One version per
67
- * package is picked, even if it ships none: a dep of the main
68
- * importer, else of any importer, else the highest version, then
69
- * DepID. Same-name skills of different packages are all returned,
70
- * sorted by rank then DepID (`linkSkills` resolves them). `mounts`
71
- * set each skill's `linked` dirs.
64
+ * Only packages in the vlt store and the project's workspaces (not its
65
+ * root) are scanned, for a root `SKILL.md`, then `<dir>/SKILL.md` in
66
+ * `skills/`, `dist/skills/` and `.agents/skills/`: the first skill of a
67
+ * name wins. One version per package is picked, even if it ships none:
68
+ * a workspace or dep of the main importer, else a dep of any importer,
69
+ * else the highest version, then DepID. Same-name skills of different
70
+ * packages are all returned, sorted by rank then DepID (`linkSkills`
71
+ * resolves them). `mounts` set each skill's `linked` dirs.
72
72
  */
73
73
  export declare const discoverSkills: (nodes: Iterable<Node>, scurry: PathScurry, mounts?: SkillMount[]) => DiscoveredSkills;
@@ -134,8 +134,11 @@ const scan = (dir, pkgName) => {
134
134
  }
135
135
  return found;
136
136
  };
137
- // 0: dep of the main importer, 1: of any importer, 2: transitive
137
+ // 0: a workspace or dep of the main importer, 1: dep of any
138
+ // importer, 2: transitive
138
139
  const rank = (node) => {
140
+ if (node.importer)
141
+ return 0;
139
142
  let r = 2;
140
143
  for (const { from } of node.edgesIn) {
141
144
  if (from.mainImporter)
@@ -162,10 +165,11 @@ const aliasOf = (node) => {
162
165
  edges.find(e => e.from.importer))?.name;
163
166
  return n === node.name ? undefined : n;
164
167
  };
165
- // store pkgs only: lockfile keeps a tarball's path as its location
166
- const scannable = (node) => !node.importer &&
167
- node.inVltStore() &&
168
- splitDepID(node.id)[0] !== 'file';
168
+ // store pkgs and workspaces, not the project root; lockfile keeps a
169
+ // tarball's path as its location
170
+ const scannable = (node) => node.importer ?
171
+ !node.mainImporter
172
+ : node.inVltStore() && splitDepID(node.id)[0] !== 'file';
169
173
  /** Ships agent skills, as `discoverSkills` finds them. */
170
174
  export const shipsSkills = (node) => scannable(node) &&
171
175
  scan(realOr(resolve(node.projectRoot, node.location)), node.name)
@@ -173,14 +177,14 @@ export const shipsSkills = (node) => scannable(node) &&
173
177
  /**
174
178
  * Find the agent skills shipped by the given installed packages.
175
179
  *
176
- * Only packages in the vlt store are scanned, for a root `SKILL.md`,
177
- * then `<dir>/SKILL.md` in `skills/`, `dist/skills/` and
178
- * `.agents/skills/`: the first skill of a name wins. One version per
179
- * package is picked, even if it ships none: a dep of the main
180
- * importer, else of any importer, else the highest version, then
181
- * DepID. Same-name skills of different packages are all returned,
182
- * sorted by rank then DepID (`linkSkills` resolves them). `mounts`
183
- * set each skill's `linked` dirs.
180
+ * Only packages in the vlt store and the project's workspaces (not its
181
+ * root) are scanned, for a root `SKILL.md`, then `<dir>/SKILL.md` in
182
+ * `skills/`, `dist/skills/` and `.agents/skills/`: the first skill of a
183
+ * name wins. One version per package is picked, even if it ships none:
184
+ * a workspace or dep of the main importer, else a dep of any importer,
185
+ * else the highest version, then DepID. Same-name skills of different
186
+ * packages are all returned, sorted by rank then DepID (`linkSkills`
187
+ * resolves them). `mounts` set each skill's `linked` dirs.
184
188
  */
185
189
  export const discoverSkills = (nodes, scurry, mounts = []) => {
186
190
  const key = (name, path) => `${lc(name)}\0${norm(path)}`;
@@ -11,3 +11,8 @@ export declare const editGitignore: (dir: string, edit: {
11
11
  add: string[];
12
12
  remove: string[];
13
13
  }) => "ok" | "refused";
14
+ /**
15
+ * Names in vlt's block of `<dir>/.gitignore`: vlt's record of its links
16
+ * in `dir`. Empty if none or unreadable.
17
+ */
18
+ export declare const gitignoreNames: (dir: string) => Set<string>;
@@ -5,6 +5,12 @@ export const GITIGNORE_END = '# <<< vlt agent skills';
5
5
  const sorted = (names) => [...names]
6
6
  .sort((a, b) => a.localeCompare(b, 'en'))
7
7
  .map(n => `/${n}`);
8
+ // vlt's block: `end` is -1 if none
9
+ const findBlock = (lines) => {
10
+ const begin = lines.findIndex(l => l.trim() === GITIGNORE_BEGIN);
11
+ const end = lines.findIndex((l, i) => begin !== -1 && i > begin && l.trim() === GITIGNORE_END);
12
+ return { begin, end };
13
+ };
8
14
  const update = (file, { add, remove }) => {
9
15
  const st = lstatSync(file, { throwIfNoEntry: false });
10
16
  if (st && !st.isFile())
@@ -12,8 +18,7 @@ const update = (file, { add, remove }) => {
12
18
  const text = st ? readFileSync(file, 'utf8') : '';
13
19
  const eol = text.includes('\r\n') ? '\r\n' : '\n';
14
20
  const lines = text.split(eol);
15
- const begin = lines.findIndex(l => l.trim() === GITIGNORE_BEGIN);
16
- const end = lines.findIndex((l, i) => begin !== -1 && i > begin && l.trim() === GITIGNORE_END);
21
+ const { begin, end } = findBlock(lines);
17
22
  let out;
18
23
  if (end === -1) {
19
24
  if (!add.length)
@@ -51,3 +56,27 @@ export const editGitignore = (dir, edit) => {
51
56
  return 'refused';
52
57
  }
53
58
  };
59
+ /**
60
+ * Names in vlt's block of `<dir>/.gitignore`: vlt's record of its links
61
+ * in `dir`. Empty if none or unreadable.
62
+ */
63
+ export const gitignoreNames = (dir) => {
64
+ try {
65
+ const file = join(dir, '.gitignore');
66
+ if (!lstatSync(file, { throwIfNoEntry: false })?.isFile()) {
67
+ return new Set();
68
+ }
69
+ const lines = readFileSync(file, 'utf8').split(/\r?\n/);
70
+ const { begin, end } = findBlock(lines);
71
+ if (end === -1)
72
+ return new Set();
73
+ return new Set(lines
74
+ .slice(begin + 1, end)
75
+ .map(l => l.trim())
76
+ .filter(l => l.startsWith('/'))
77
+ .map(l => l.slice(1)));
78
+ }
79
+ catch {
80
+ return new Set();
81
+ }
82
+ };
@@ -109,15 +109,16 @@ export declare const listSkills: (o: SkillsOptions) => Promise<ListSkillsResult>
109
109
  * project's agent skill dirs (see `skillsDirs`), created only when a
110
110
  * skill is picked. `target` selectors may end with `:skill()`. One
111
111
  * package per name, among picked skills, in every dir: the package of
112
- * the first existing link (in dir order), else rank (main importer
113
- * dep, importer dep, transitive), then DepID; the others are
112
+ * the first existing link (in dir order), else rank (workspace or main
113
+ * importer dep, importer dep, transitive), then DepID; the others are
114
114
  * reported. Installs keep a dir's link to another installed skill.
115
115
  * A link follows its package to the picked version, in every scanned
116
116
  * dir. Then remove vlt links whose skill is no longer installed (with
117
117
  * `apply`: every vlt link not made here), and keep each dir's
118
- * `.gitignore` block in sync. Linked packages get private copies of
119
- * files hardlinked from the global store. Security queries fail
120
- * closed: skills of packages with no security data are not linked,
121
- * and reported in `unscanned` unless still linked.
118
+ * `.gitignore` block in sync (also vlt's record of workspace links).
119
+ * Linked packages get private copies of files hardlinked from the
120
+ * global store. Security queries fail closed: skills of packages with
121
+ * no security data are not linked, and reported in `unscanned` unless
122
+ * still linked.
122
123
  */
123
124
  export declare const linkSkills: (o: LinkSkillsOptions) => Promise<LinkSkillsResult>;
@@ -1,4 +1,5 @@
1
1
  import { existsSync } from 'node:fs';
2
+ import { Monorepo } from '@vltpkg/workspaces';
2
3
  import { load as loadActual } from "../actual/load.js";
3
4
  import { filterNodesByQueryParts } from "../filter-nodes-by-query.js";
4
5
  import { unshare } from "../reify/unshare.js";
@@ -6,7 +7,7 @@ import { skillsDirs } from "./dirs.js";
6
7
  import { discoverSkills } from "./discover.js";
7
8
  import { editGitignore } from "./gitignore.js";
8
9
  import { maskSkills } from "./mask.js";
9
- import { mountSkill, packageKey, readMounts, samePath, unmountSkill, } from "./mounts.js";
10
+ import { mountSkill, packageKey, readMounts, realOr, samePath, unmountSkill, } from "./mounts.js";
10
11
  export * from "./discover.js";
11
12
  const lc = (s) => s.toLowerCase();
12
13
  const selectNodes = async (o) => {
@@ -21,6 +22,23 @@ const dirsOf = (o, create = false) => skillsDirs({
21
22
  env: o.env,
22
23
  create,
23
24
  });
25
+ // every workspace, selected or not: owners of vlt links
26
+ const workspaceDirs = (graph, scurry) => [...graph.importers]
27
+ .filter(n => !n.mainImporter)
28
+ .map(n => ({
29
+ id: n.id,
30
+ path: realOr(n.resolvedLocation(scurry)),
31
+ }));
32
+ // every workspace, loaded or not (`-w`)
33
+ const allWorkspaces = (m) => m ?
34
+ [
35
+ ...Monorepo.load(m.projectRoot, {
36
+ config: m.config,
37
+ scurry: m.scurry,
38
+ packageJson: m.packageJson,
39
+ }).values(),
40
+ ].map(ws => ({ id: ws.id, path: realOr(ws.fullpath) }))
41
+ : [];
24
42
  // packages behind live vlt links
25
43
  const liveOwners = (mounts) => mounts.flatMap(m => m.owner && existsSync(m.target) ? [m.owner] : []);
26
44
  // private copies of store-hardlinked files: edits via skill links
@@ -61,7 +79,7 @@ const report = (dropped, unscanned) => unscanned && dropped.length ?
61
79
  export const listSkills = async (o) => {
62
80
  const { graph, nodes, parts, unscanned } = await selectNodes(o);
63
81
  const { targets, scan, skipped } = dirsOf(o);
64
- const { skills } = discoverSkills(nodes, o.scurry, readMounts(scan));
82
+ const { skills } = discoverSkills(nodes, o.scurry, readMounts(scan, { workspaces: workspaceDirs(graph, o.scurry) }));
65
83
  const { picked, dropped } = pick(skills, parts, unscanned);
66
84
  return {
67
85
  project: graph.mainImporter.name,
@@ -77,16 +95,17 @@ export const listSkills = async (o) => {
77
95
  * project's agent skill dirs (see `skillsDirs`), created only when a
78
96
  * skill is picked. `target` selectors may end with `:skill()`. One
79
97
  * package per name, among picked skills, in every dir: the package of
80
- * the first existing link (in dir order), else rank (main importer
81
- * dep, importer dep, transitive), then DepID; the others are
98
+ * the first existing link (in dir order), else rank (workspace or main
99
+ * importer dep, importer dep, transitive), then DepID; the others are
82
100
  * reported. Installs keep a dir's link to another installed skill.
83
101
  * A link follows its package to the picked version, in every scanned
84
102
  * dir. Then remove vlt links whose skill is no longer installed (with
85
103
  * `apply`: every vlt link not made here), and keep each dir's
86
- * `.gitignore` block in sync. Linked packages get private copies of
87
- * files hardlinked from the global store. Security queries fail
88
- * closed: skills of packages with no security data are not linked,
89
- * and reported in `unscanned` unless still linked.
104
+ * `.gitignore` block in sync (also vlt's record of workspace links).
105
+ * Linked packages get private copies of files hardlinked from the
106
+ * global store. Security queries fail closed: skills of packages with
107
+ * no security data are not linked, and reported in `unscanned` unless
108
+ * still linked.
90
109
  */
91
110
  export const linkSkills = async (o) => {
92
111
  const { graph, nodes, parts, unscanned } = await selectNodes(o);
@@ -101,7 +120,8 @@ export const linkSkills = async (o) => {
101
120
  conflicts: [],
102
121
  dirs: targets.map(d => d.dir),
103
122
  };
104
- const mounts = readMounts(scan);
123
+ const workspaces = workspaceDirs(graph, o.scurry);
124
+ const mounts = readMounts(scan, { workspaces });
105
125
  const byName = new Map(mounts.map(m => [`${m.dir.dir}\0${lc(m.name)}`, m]));
106
126
  // the link's skill is no longer installed: dangling, orphan owner,
107
127
  // or a scanned owner that no longer ships it
@@ -163,7 +183,10 @@ export const linkSkills = async (o) => {
163
183
  // names taken over by entries not vlt's
164
184
  const foreign = [];
165
185
  const kept = new Set();
186
+ // links made here, recorded even if not yet in `.gitignore`
187
+ const made = new Set();
166
188
  const where = new Map();
189
+ let every;
167
190
  for (const { dir, winner, losers, m } of picks) {
168
191
  if (winner) {
169
192
  // replaced link named in another case (case-sensitive fs)
@@ -172,10 +195,16 @@ export const linkSkills = async (o) => {
172
195
  gone.push(m);
173
196
  }
174
197
  const mount = `${dir.dir}/${winner.skill}`;
175
- const r = mountSkill(winner, dir);
198
+ const r = mountSkill(winner, dir, m?.name === winner.skill);
199
+ if (r === 'linked')
200
+ made.add(mount);
176
201
  if (r === 'conflict') {
177
202
  res.conflicts.push(mount);
178
- foreign.push({ dir, name: winner.skill });
203
+ // vlt's link into a workspace not loaded (`-w`) keeps its line
204
+ every ??= allWorkspaces(o.monorepo);
205
+ if (!readMounts([dir], { workspaces: every }).some(l => l.name === winner.skill)) {
206
+ foreign.push({ dir, name: winner.skill });
207
+ }
179
208
  }
180
209
  else {
181
210
  const k = `${winner.id}\0${winner.skill}`;
@@ -189,7 +218,7 @@ export const linkSkills = async (o) => {
189
218
  for (const s of losers)
190
219
  res.conflicts.push(`${dir.dir}/${s.skill} (${label(s)})`);
191
220
  }
192
- const after = readMounts(scan);
221
+ const after = readMounts(scan, { workspaces, made });
193
222
  const present = [];
194
223
  for (const m of after) {
195
224
  if (o.apply ? kept.has(m.mount) : !stale(m)) {
@@ -16,9 +16,17 @@ export type SkillMount = {
16
16
  link: string;
17
17
  /** absolute resolved link target */
18
18
  target: string;
19
- /** DepID of the store dir the link points into */
19
+ /** DepID of the store dir or workspace the link points into */
20
20
  owner?: DepID;
21
21
  };
22
+ /**
23
+ * A workspace of the project.
24
+ */
25
+ export type WorkspaceDir = {
26
+ id: DepID;
27
+ /** realpath */
28
+ path: string;
29
+ };
22
30
  /**
23
31
  * Outcome of mounting a single skill.
24
32
  */
@@ -50,14 +58,20 @@ export declare const ownerOf: (target: string) => DepID | undefined;
50
58
  */
51
59
  export declare const packageKey: (id: DepID) => string;
52
60
  /**
53
- * All vlt links in the given dirs. Anything else is ignored.
61
+ * All vlt links in the given dirs: links into a `node_modules/.vlt`
62
+ * store, and links listed in the dir's `.gitignore` block (or `made`)
63
+ * into a workspace or dangling. Anything else is ignored.
54
64
  */
55
- export declare const readMounts: (scan: SkillsDir[]) => SkillMount[];
65
+ export declare const readMounts: (scan: SkillsDir[], { workspaces, made, }?: {
66
+ workspaces?: WorkspaceDir[];
67
+ made?: Set<string>;
68
+ }) => SkillMount[];
56
69
  /**
57
- * Link a skill at `<dir>/<skill>`. Never touches anything not managed by
58
- * vlt: returns `'conflict'` instead.
70
+ * Link a skill at `<dir>/<skill>`. `'unchanged'` if the entry already
71
+ * is the skill. Never touches anything not managed by vlt (a store
72
+ * link, or `owned`): returns `'conflict'` instead.
59
73
  */
60
- export declare const mountSkill: (skill: Pick<Skill, "skill" | "path">, dir: SkillsDir) => MountResult;
74
+ export declare const mountSkill: (skill: Pick<Skill, "skill" | "path">, dir: SkillsDir, owned?: boolean) => MountResult;
61
75
  /**
62
76
  * Remove a vlt link. Its dir is kept.
63
77
  */
@@ -1,7 +1,8 @@
1
- import { lstatSync, readdirSync, readlinkSync, realpathSync, unlinkSync, } from 'node:fs';
1
+ import { existsSync, lstatSync, readdirSync, readlinkSync, realpathSync, unlinkSync, } from 'node:fs';
2
2
  import { dirname, join, relative, resolve, sep } from 'node:path';
3
3
  import { baseDepID, isDepID, joinDepIDTuple, splitDepID, } from '@vltpkg/dep-id';
4
4
  import { symlinkSyncMkdirp } from "../reify/symlink-sync.js";
5
+ import { gitignoreNames } from "./gitignore.js";
5
6
  /** `lstat`, undefined when missing or unreadable (eg, ENOTDIR) */
6
7
  export const lstat = (path) => {
7
8
  try {
@@ -30,6 +31,15 @@ export const realOr = (p) => {
30
31
  */
31
32
  export const samePath = (a, b) => norm(realOr(a)) === norm(realOr(b));
32
33
  const storeSeg = `${sep}node_modules${sep}.vlt${sep}`;
34
+ // resolved target of a symlink (or junction)
35
+ const linkTarget = (link) => {
36
+ if (!lstat(link)?.isSymbolicLink())
37
+ return undefined;
38
+ let raw = readlinkSync(link);
39
+ if (raw.startsWith('\\\\?\\'))
40
+ raw = raw.slice(4);
41
+ return resolve(dirname(link), raw);
42
+ };
33
43
  /**
34
44
  * The target of `link` if it is a symlink (or junction) pointing into
35
45
  * a `node_modules/.vlt` store, otherwise undefined. Any store, not just
@@ -37,13 +47,20 @@ const storeSeg = `${sep}node_modules${sep}.vlt${sep}`;
37
47
  * store after the project is moved or copied.
38
48
  */
39
49
  export const mountTarget = (link) => {
40
- if (!lstat(link)?.isSymbolicLink())
41
- return undefined;
42
- let raw = readlinkSync(link);
43
- if (raw.startsWith('\\\\?\\'))
44
- raw = raw.slice(4);
45
- const target = resolve(dirname(link), raw);
46
- return norm(target).includes(storeSeg) ? target : undefined;
50
+ const target = linkTarget(link);
51
+ return target && norm(target).includes(storeSeg) ?
52
+ target
53
+ : undefined;
54
+ };
55
+ // the deepest workspace holding `target`
56
+ const workspaceOf = (target, workspaces) => {
57
+ const t = norm(realOr(target));
58
+ return workspaces
59
+ .filter(({ path }) => {
60
+ const p = norm(path);
61
+ return t === p || t.startsWith(p + sep);
62
+ })
63
+ .sort((a, b) => b.path.length - a.path.length)[0];
47
64
  };
48
65
  /**
49
66
  * The DepID of the last `node_modules/.vlt/<DepID>` segment of a raw
@@ -76,9 +93,11 @@ export const packageKey = (id) => {
76
93
  ]);
77
94
  };
78
95
  /**
79
- * All vlt links in the given dirs. Anything else is ignored.
96
+ * All vlt links in the given dirs: links into a `node_modules/.vlt`
97
+ * store, and links listed in the dir's `.gitignore` block (or `made`)
98
+ * into a workspace or dangling. Anything else is ignored.
80
99
  */
81
- export const readMounts = (scan) => {
100
+ export const readMounts = (scan, { workspaces = [], made, } = {}) => {
82
101
  const mounts = [];
83
102
  for (const dir of scan) {
84
103
  let entries;
@@ -88,35 +107,42 @@ export const readMounts = (scan) => {
88
107
  catch {
89
108
  continue;
90
109
  }
110
+ const listed = gitignoreNames(dir.real);
91
111
  for (const name of entries) {
92
112
  const link = join(dir.real, name);
113
+ const mount = `${dir.dir}/${name}`;
114
+ const m = { dir, name, mount, link };
93
115
  const target = mountTarget(link);
94
116
  if (target) {
95
- mounts.push({
96
- dir,
97
- name,
98
- mount: `${dir.dir}/${name}`,
99
- link,
100
- target,
101
- owner: ownerOf(target),
102
- });
117
+ mounts.push({ ...m, target, owner: ownerOf(target) });
118
+ continue;
103
119
  }
120
+ const wsTarget = (listed.has(name) || made?.has(mount)) && linkTarget(link);
121
+ if (!wsTarget)
122
+ continue;
123
+ if (!existsSync(wsTarget)) {
124
+ mounts.push({ ...m, target: wsTarget });
125
+ continue;
126
+ }
127
+ const ws = workspaceOf(wsTarget, workspaces);
128
+ if (ws)
129
+ mounts.push({ ...m, target: wsTarget, owner: ws.id });
104
130
  }
105
131
  }
106
132
  return mounts.sort((a, b) => a.mount.localeCompare(b.mount, 'en'));
107
133
  };
108
134
  /**
109
- * Link a skill at `<dir>/<skill>`. Never touches anything not managed by
110
- * vlt: returns `'conflict'` instead.
135
+ * Link a skill at `<dir>/<skill>`. `'unchanged'` if the entry already
136
+ * is the skill. Never touches anything not managed by vlt (a store
137
+ * link, or `owned`): returns `'conflict'` instead.
111
138
  */
112
- export const mountSkill = (skill, dir) => {
139
+ export const mountSkill = (skill, dir, owned = false) => {
113
140
  const link = join(dir.real, skill.skill);
114
141
  if (lstat(link)) {
115
- const target = mountTarget(link);
116
- if (!target)
117
- return 'conflict';
118
- if (samePath(target, skill.path))
142
+ if (samePath(link, skill.path))
119
143
  return 'unchanged';
144
+ if (!owned && !mountTarget(link))
145
+ return 'conflict';
120
146
  unlinkSync(link);
121
147
  }
122
148
  // junctions need no admin rights, but want an absolute target
@@ -1,3 +1,4 @@
1
+ import type { DepID } from '@vltpkg/dep-id';
1
2
  import type { PackageInfoClient } from '@vltpkg/package-info';
2
3
  import type { LoadOptions } from './actual/load.ts';
3
4
  import type { RemoveImportersDependenciesMap } from './dependencies.ts';
@@ -18,6 +19,8 @@ export type UninstallOptions = LoadOptions & {
18
19
  * none
19
20
  */
20
21
  skillsDirs?: string[];
22
+ /** store dirs of these nodes are never removed, eg the running vlt */
23
+ keepNodes?: ReadonlySet<DepID>;
21
24
  };
22
25
  /**
23
26
  * Result of {@link uninstall}.
package/dist/uninstall.js CHANGED
@@ -68,6 +68,7 @@ export const uninstall = async (options, remove) => {
68
68
  ...options,
69
69
  remove,
70
70
  graph,
71
+ modifiers,
71
72
  })
72
73
  : undefined;
73
74
  saveImportersPackageJson?.();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@vltpkg/graph",
3
3
  "description": "A library that helps understanding & expressing what happens on an install",
4
- "version": "1.5.0",
4
+ "version": "1.6.0",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/vltpkg/vltpkg.git",
@@ -13,26 +13,26 @@
13
13
  "url": "http://vlt.sh"
14
14
  },
15
15
  "dependencies": {
16
- "@vltpkg/cmd-shim": "1.5.0",
17
- "@vltpkg/dep-id": "1.5.0",
18
- "@vltpkg/dss-breadcrumb": "1.5.0",
19
- "@vltpkg/error-cause": "1.5.0",
20
- "@vltpkg/fast-split": "1.5.0",
21
- "@vltpkg/graph-run": "1.5.0",
22
- "@vltpkg/init": "1.5.0",
23
- "@vltpkg/output": "1.5.0",
24
- "@vltpkg/package-info": "1.5.0",
25
- "@vltpkg/package-json": "1.5.0",
26
- "@vltpkg/pick-manifest": "1.5.0",
27
- "@vltpkg/query": "1.5.0",
28
- "@vltpkg/rollback-remove": "1.5.0",
29
- "@vltpkg/run": "1.5.0",
30
- "@vltpkg/satisfies": "1.5.0",
31
- "@vltpkg/security-archive": "1.5.0",
32
- "@vltpkg/spec": "1.5.0",
33
- "@vltpkg/types": "1.5.0",
34
- "@vltpkg/vlt-json": "1.5.0",
35
- "@vltpkg/workspaces": "1.5.0",
16
+ "@vltpkg/cmd-shim": "1.6.0",
17
+ "@vltpkg/dep-id": "1.6.0",
18
+ "@vltpkg/dss-breadcrumb": "1.6.0",
19
+ "@vltpkg/error-cause": "1.6.0",
20
+ "@vltpkg/fast-split": "1.6.0",
21
+ "@vltpkg/graph-run": "1.6.0",
22
+ "@vltpkg/init": "1.6.0",
23
+ "@vltpkg/output": "1.6.0",
24
+ "@vltpkg/package-info": "1.6.0",
25
+ "@vltpkg/package-json": "1.6.0",
26
+ "@vltpkg/pick-manifest": "1.6.0",
27
+ "@vltpkg/query": "1.6.0",
28
+ "@vltpkg/rollback-remove": "1.6.0",
29
+ "@vltpkg/run": "1.6.0",
30
+ "@vltpkg/satisfies": "1.6.0",
31
+ "@vltpkg/security-archive": "1.6.0",
32
+ "@vltpkg/spec": "1.6.0",
33
+ "@vltpkg/types": "1.6.0",
34
+ "@vltpkg/vlt-json": "1.6.0",
35
+ "@vltpkg/workspaces": "1.6.0",
36
36
  "path-scurry": "^2.0.1",
37
37
  "promise-call-limit": "^3.0.2"
38
38
  },