@pnpm/workspace.injected-deps-syncer 1100.0.32 → 1100.0.34

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/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # @pnpm/workspace.injected-deps-syncer
2
2
 
3
+ ## 1100.0.34
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies:
8
+ - @pnpm/bins.linker@1100.0.28
9
+ - @pnpm/bins.remover@1100.0.21
10
+ - @pnpm/bins.resolver@1100.0.15
11
+ - @pnpm/error@1100.1.3
12
+ - @pnpm/fetching.directory-fetcher@1100.0.30
13
+ - @pnpm/installing.modules-yaml@1101.0.0
14
+ - @pnpm/pkg-manifest.reader@1100.0.17
15
+ - @pnpm/types@1102.0.0
16
+ - @pnpm/workspace.projects-reader@1101.0.24
17
+
18
+ ## 1100.0.33
19
+
20
+ ### Patch Changes
21
+
22
+ - `syncInjectedDepsAfterScripts` no longer fails with `ERR_PNPM_UNSUPPORTED_INODE_TYPE` when a workspace package contains an inode that is neither a file nor a directory, such as the FIFO 1Password's environments create for `.env`. Such an inode cannot be hardlinked into the injected copy, so it is skipped and the rest of the package still syncs [#13550](https://github.com/pnpm/pnpm/issues/13550).
23
+
24
+ `syncInjectedDepsAfterScripts` also no longer fails with `EEXIST` when a workspace package replaced a file with a directory of the same name since the injected copy was last synced.
25
+
26
+ - `syncInjectedDepsAfterScripts` no longer fails with `ENOTDIR` when a workspace package replaced a directory with a file of the same name and the injected copy still held that directory's contents.
27
+
28
+ - `syncInjectedDepsAfterScripts` now removes the bin link of a bin the script dropped. Previously only new bins were linked, so a build step that stopped declaring one left its shim behind, pointing at a command that was no longer there.
29
+
30
+ - `syncInjectedDepsAfterScripts` now identifies a file by its device as well as its inode number. An inode number is only unique within one filesystem, so on its own it could match an unrelated file on another device and leave that path stale in the injected copy.
31
+
32
+ - Updated dependencies:
33
+ - @pnpm/bins.linker@1100.0.27
34
+ - @pnpm/bins.remover@1100.0.20
35
+ - @pnpm/error@1100.1.2
36
+ - @pnpm/fetching.directory-fetcher@1100.0.29
37
+ - @pnpm/pkg-manifest.reader@1100.0.16
38
+ - @pnpm/workspace.projects-reader@1101.0.23
39
+
3
40
  ## 1100.0.32
4
41
 
5
42
  ### Patch Changes
@@ -1,6 +1,12 @@
1
1
  import fs from 'node:fs';
2
2
  export declare const DIR: unique symbol;
3
- export type File = number;
3
+ /**
4
+ * A file's identity, as `<device>:<inode>`. An inode number is only unique
5
+ * within one filesystem, so the device it came from is part of the identity:
6
+ * without it two unrelated files on different devices can collide and be
7
+ * taken for the same file, leaving the injected copy stale.
8
+ */
9
+ export type File = string;
4
10
  export type Dir = typeof DIR;
5
11
  export type Value = File | Dir;
6
12
  export type InodeMap = Record<string, Value>;
@@ -43,7 +49,7 @@ export declare function diffDir(oldIndex: InodeMap, newIndex: InodeMap): DirDiff
43
49
  * The {@link optimizedDirPatch} is assumed to be already optimized (i.e. `removed` is already reversed).
44
50
  */
45
51
  export declare function applyPatch(optimizedDirPatch: DirDiff, sourceDir: string, targetDir: string): Promise<void>;
46
- export type ExtendFilesMapStats = Pick<fs.Stats, 'ino' | 'isFile' | 'isDirectory'>;
52
+ export type ExtendFilesMapStats = Pick<fs.Stats, 'dev' | 'ino' | 'isFile' | 'isDirectory'>;
47
53
  export interface ExtendFilesMapOptions {
48
54
  /** Map relative path of each file to their real path */
49
55
  filesMap: Map<string, string>;
package/lib/DirPatcher.js CHANGED
@@ -1,7 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import util from 'node:util';
4
- import { PnpmError } from '@pnpm/error';
5
4
  import { fetchFromDir } from '@pnpm/fetching.directory-fetcher';
6
5
  export const DIR = Symbol('Path is a directory');
7
6
  // length comparison should place every directory before the files it contains because
@@ -36,16 +35,34 @@ export function diffDir(oldIndex, newIndex) {
36
35
  export async function applyPatch(optimizedDirPatch, sourceDir, targetDir) {
37
36
  async function addRecursive(sourcePath, targetPath, value) {
38
37
  if (value === DIR) {
39
- await fs.promises.mkdir(targetPath, { recursive: true });
38
+ await retryOverBlockingInode(targetPath, async () => fs.promises.mkdir(targetPath, { recursive: true }));
40
39
  }
41
- else if (typeof value === 'number') {
40
+ else if (typeof value === 'string') {
42
41
  fs.mkdirSync(path.dirname(targetPath), { recursive: true });
43
- await fs.promises.link(sourcePath, targetPath);
42
+ await retryOverBlockingInode(targetPath, async () => fs.promises.link(sourcePath, targetPath));
44
43
  }
45
44
  else {
46
45
  const _ = value; // static type guard
47
46
  }
48
47
  }
48
+ /**
49
+ * The target may hold an inode that {@link extendFilesMap} skips — a FIFO, a
50
+ * socket, a device. The diff cannot see it, so it is never scheduled for
51
+ * removal, and adding over it fails with `EEXIST`. Clear that path and retry
52
+ * once instead of aborting the sync partway through.
53
+ */
54
+ async function retryOverBlockingInode(targetPath, add) {
55
+ try {
56
+ await add();
57
+ }
58
+ catch (error) {
59
+ if (!util.types.isNativeError(error) || !('code' in error) || (error.code !== 'EEXIST')) {
60
+ throw error;
61
+ }
62
+ await removeRecursive(targetPath);
63
+ await add();
64
+ }
65
+ }
49
66
  async function removeRecursive(targetPath) {
50
67
  try {
51
68
  await fs.promises.rm(targetPath, { recursive: true, force: true });
@@ -56,24 +73,33 @@ export async function applyPatch(optimizedDirPatch, sourceDir, targetDir) {
56
73
  }
57
74
  }
58
75
  }
59
- const adding = Promise.all(optimizedDirPatch.added.map(async (item) => {
60
- const sourcePath = path.join(sourceDir, item.path);
61
- const targetPath = path.join(targetDir, item.path);
62
- await addRecursive(sourcePath, targetPath, item.newValue);
63
- }));
64
- const removing = Promise.all(optimizedDirPatch.removed.map(async (item) => {
65
- const targetPath = path.join(targetDir, item.path);
66
- await removeRecursive(targetPath);
67
- }));
68
- const modifying = Promise.all(optimizedDirPatch.modified.map(async (item) => {
76
+ async function applyChange(item) {
69
77
  const sourcePath = path.join(sourceDir, item.path);
70
78
  const targetPath = path.join(targetDir, item.path);
71
- if (item.oldValue === item.newValue)
72
- return;
73
- await removeRecursive(targetPath);
79
+ if (item.oldValue !== undefined) {
80
+ await removeRecursive(targetPath);
81
+ }
74
82
  await addRecursive(sourcePath, targetPath, item.newValue);
83
+ }
84
+ const changes = [...optimizedDirPatch.added, ...optimizedDirPatch.modified]
85
+ .filter(item => item.oldValue !== item.newValue);
86
+ const newDirs = changes.filter(item => item.newValue === DIR).sort((a, b) => comparePaths(a.path, b.path));
87
+ const newFiles = changes.filter(item => item.newValue !== DIR);
88
+ // The phase order is load-bearing twice over. Removals go first, so a path
89
+ // the source turned from a directory into a file still has a directory in it
90
+ // when its dropped children are unlinked. Directories then go in ahead of the
91
+ // files they hold, so a directory is always empty when it displaces what the
92
+ // target held at its path — otherwise a removal landing late would take out
93
+ // files a sibling had already linked. A path the target holds as a file and
94
+ // the source as a directory lands in `modified` rather than `added`, so both
95
+ // arrays feed the directory pass.
96
+ await Promise.all(optimizedDirPatch.removed.map(async (item) => {
97
+ await removeRecursive(path.join(targetDir, item.path));
75
98
  }));
76
- await Promise.all([adding, removing, modifying]);
99
+ for (const item of newDirs) {
100
+ await applyChange(item); // eslint-disable-line no-await-in-loop
101
+ }
102
+ await Promise.all(newFiles.map(applyChange));
77
103
  }
78
104
  /**
79
105
  * Convert a pair of a files index map, which is a map from relative path of each file to their real paths,
@@ -93,17 +119,17 @@ export async function extendFilesMap({ filesMap, filesStats }) {
93
119
  await Promise.all(Array.from(filesMap.entries()).map(async ([relativePath, realPath]) => {
94
120
  const stats = filesStats?.[relativePath] ?? await fs.promises.stat(realPath);
95
121
  if (stats.isFile()) {
96
- addInodeAndAncestors(relativePath, stats.ino);
122
+ addInodeAndAncestors(relativePath, fileId(stats));
97
123
  }
98
124
  else if (stats.isDirectory()) {
99
125
  addInodeAndAncestors(relativePath, DIR);
100
126
  }
101
- else {
102
- throw new PnpmError('UNSUPPORTED_INODE_TYPE', `Filesystem inode at ${realPath} is neither a file, a directory, or a symbolic link`);
103
- }
127
+ // Anything else — a FIFO, a socket, a device — cannot be hardlinked into
128
+ // the injected copy, so it is left out of the map.
104
129
  }));
105
130
  return result;
106
131
  }
132
+ const fileId = (stats) => `${stats.dev}:${stats.ino}`;
107
133
  export class DirPatcher {
108
134
  sourceDir;
109
135
  targetDir;
package/lib/index.d.ts CHANGED
@@ -1,6 +1,14 @@
1
+ import type { DependencyManifest } from '@pnpm/types';
1
2
  export interface SyncInjectedDepsOptions {
2
3
  pkgName: string | undefined;
3
4
  pkgRootDir: string;
4
5
  workspaceDir: string | undefined;
6
+ /**
7
+ * The package's manifest as it was before the scripts ran. A script that
8
+ * drops a bin leaves its shim behind, and the copies cannot say which bins
9
+ * they used to have: their `package.json` is hardlinked to the source, so
10
+ * an in-place rewrite has already reached them.
11
+ */
12
+ manifestBeforeScripts?: DependencyManifest;
5
13
  }
6
14
  export declare function syncInjectedDeps(opts: SyncInjectedDepsOptions): Promise<void>;
package/lib/index.js CHANGED
@@ -1,5 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import { linkBins, linkBinsOfPackages } from '@pnpm/bins.linker';
3
+ import { removeBin } from '@pnpm/bins.remover';
4
+ import { getBinsFromPackageManifest } from '@pnpm/bins.resolver';
3
5
  import { PnpmError } from '@pnpm/error';
4
6
  import { readModulesManifest } from '@pnpm/installing.modules-yaml';
5
7
  import { logger as createLogger } from '@pnpm/logger';
@@ -41,21 +43,53 @@ export async function syncInjectedDeps(opts) {
41
43
  });
42
44
  return;
43
45
  }
44
- const patchers = await DirPatcher.fromMultipleTargets(pkgRootDir, targetDirs.map(targetDir => path.resolve(opts.workspaceDir, targetDir)));
46
+ const resolvedTargetDirs = targetDirs.map(targetDir => path.resolve(opts.workspaceDir, targetDir));
47
+ const patchers = await DirPatcher.fromMultipleTargets(pkgRootDir, resolvedTargetDirs);
45
48
  await Promise.all(patchers.map(patcher => patcher.apply()));
46
- // After syncing files, also sync bin links if the package has binaries
47
- await syncBinLinks(pkgRootDir, targetDirs, opts.workspaceDir);
49
+ await syncBinLinks({
50
+ // The install hoists bins into the virtual store's own `.bin` as well.
51
+ hoistedBinDir: modules.virtualStoreDir == null
52
+ ? undefined
53
+ : path.join(path.resolve(opts.workspaceDir, modules.virtualStoreDir), 'node_modules', '.bin'),
54
+ pkgRootDir,
55
+ previousBinNames: opts.manifestBeforeScripts == null
56
+ ? []
57
+ : (await getBinsFromPackageManifest(opts.manifestBeforeScripts, pkgRootDir)).map(command => command.name),
58
+ resolvedTargetDirs,
59
+ workspaceDir: opts.workspaceDir,
60
+ });
61
+ }
62
+ /** The commands a package declares, or none when it declares no bins. */
63
+ async function readBinNames(pkgDir) {
64
+ const manifest = await safeReadPackageJsonFromDir(pkgDir);
65
+ if (!manifest?.name)
66
+ return [];
67
+ const commands = await getBinsFromPackageManifest(manifest, pkgDir);
68
+ return commands.map(command => command.name);
48
69
  }
49
- async function syncBinLinks(pkgRootDir, targetDirs, workspaceDir) {
50
- const manifest = await safeReadPackageJsonFromDir(pkgRootDir);
51
- if (!manifest?.bin || !manifest?.name) {
70
+ async function syncBinLinks(opts) {
71
+ const manifest = await safeReadPackageJsonFromDir(opts.pkgRootDir);
72
+ if (!manifest?.name) {
52
73
  return;
53
74
  }
75
+ // A script can drop a bin as easily as it can add one. `linkBins` only ever
76
+ // creates shims, so without this the shim for a dropped bin survives and
77
+ // points at a command that is no longer there.
78
+ const currentBinNames = new Set(await readBinNames(opts.pkgRootDir));
79
+ const staleBinNames = opts.previousBinNames.filter(name => !currentBinNames.has(name));
54
80
  // Step 1: Link bins in .pnpm virtual store
55
- const binLinkPromises = targetDirs.map(async (targetDir) => {
56
- const resolvedTargetDir = path.resolve(workspaceDir, targetDir);
81
+ const binLinkPromises = opts.resolvedTargetDirs.map(async (resolvedTargetDir) => {
57
82
  const parentNodeModulesDir = path.dirname(resolvedTargetDir);
58
83
  const binDir = path.join(parentNodeModulesDir, '.bin');
84
+ // The installer writes an injected package's own bins inside the copy,
85
+ // while this function writes them beside it. A dropped bin has to be
86
+ // cleared from both, or the one this function never wrote survives.
87
+ const binDirs = [binDir, path.join(resolvedTargetDir, 'node_modules', '.bin')];
88
+ if (opts.hoistedBinDir != null)
89
+ binDirs.push(opts.hoistedBinDir);
90
+ await Promise.all(binDirs.flatMap(dir => staleBinNames.map(async (name) => removeBin(path.join(dir, name)))));
91
+ if (manifest.bin == null)
92
+ return;
59
93
  await linkBinsOfPackages([{
60
94
  manifest,
61
95
  location: resolvedTargetDir,
@@ -65,10 +99,14 @@ async function syncBinLinks(pkgRootDir, targetDirs, workspaceDir) {
65
99
  // We need to relink bins for all workspace projects because injected deps
66
100
  // can be used by any project in the workspace. We relink all bins (not just
67
101
  // this package) to ensure consistency.
68
- const allProjects = await findWorkspaceProjectsNoCheck(workspaceDir, {});
102
+ const allProjects = await findWorkspaceProjectsNoCheck(opts.workspaceDir, {});
69
103
  const consumerLinkPromises = allProjects.map(async (project) => {
70
104
  const projectNodeModules = path.join(project.rootDir, 'node_modules');
71
105
  const projectBinDir = path.join(projectNodeModules, '.bin');
106
+ // A stale name another package legitimately owns is put back by the
107
+ // relink below, so removing first costs nothing and catches the shim
108
+ // this package left behind.
109
+ await Promise.all(staleBinNames.map(async (name) => removeBin(path.join(projectBinDir, name))));
72
110
  // Relink all bins in the project's node_modules
73
111
  await linkBins(projectNodeModules, projectBinDir, {
74
112
  allowExoticManifests: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pnpm/workspace.injected-deps-syncer",
3
- "version": "1100.0.32",
3
+ "version": "1100.0.34",
4
4
  "description": "Update all injected replica of a workspace package",
5
5
  "keywords": [
6
6
  "pnpm",
@@ -27,14 +27,16 @@
27
27
  "!*.map"
28
28
  ],
29
29
  "dependencies": {
30
- "@pnpm/bins.linker": "1100.0.26",
31
- "@pnpm/error": "1100.1.1",
32
- "@pnpm/fetching.directory-fetcher": "1100.0.28",
33
- "@pnpm/installing.modules-yaml": "1100.0.15",
34
- "@pnpm/pkg-manifest.reader": "1100.0.15",
30
+ "@pnpm/bins.linker": "1100.0.28",
31
+ "@pnpm/bins.remover": "1100.0.21",
32
+ "@pnpm/bins.resolver": "1100.0.15",
33
+ "@pnpm/error": "1100.1.3",
34
+ "@pnpm/fetching.directory-fetcher": "1100.0.30",
35
+ "@pnpm/installing.modules-yaml": "1101.0.0",
36
+ "@pnpm/pkg-manifest.reader": "1100.0.17",
35
37
  "@pnpm/text.ordinal-comparator": "1100.0.0",
36
- "@pnpm/types": "1101.9.0",
37
- "@pnpm/workspace.projects-reader": "1101.0.22",
38
+ "@pnpm/types": "1102.0.0",
39
+ "@pnpm/workspace.projects-reader": "1101.0.24",
38
40
  "@types/normalize-path": "^3.0.2",
39
41
  "normalize-path": "^3.0.0"
40
42
  },
@@ -44,8 +46,10 @@
44
46
  "devDependencies": {
45
47
  "@jest/globals": "30.4.1",
46
48
  "@pnpm/logger": "1100.0.0",
47
- "@pnpm/prepare": "1100.0.25",
48
- "@pnpm/workspace.injected-deps-syncer": "1100.0.32"
49
+ "@pnpm/prepare": "1100.0.27",
50
+ "@pnpm/workspace.injected-deps-syncer": "1100.0.34",
51
+ "@types/is-windows": "^1.0.2",
52
+ "is-windows": "^1.0.2"
49
53
  },
50
54
  "engines": {
51
55
  "node": ">=22.13"