@pnpm/bins.linker 1100.0.13 → 1100.0.15

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.
Files changed (2) hide show
  1. package/lib/index.js +123 -7
  2. package/package.json +11 -11
package/lib/index.js CHANGED
@@ -200,15 +200,23 @@ async function linkBin(cmd, binsDir, opts) {
200
200
  catch { }
201
201
  if (IS_WINDOWS) {
202
202
  const exePath = path.join(binsDir, `${cmd.name}${getExeExtension()}`);
203
+ // node.exe is the only bin pnpm links directly as a real executable rather
204
+ // than through a cmd-shim, so the existing-exe handling only applies to it.
205
+ // We could update our own cmd shims to support node.cmd, but we can't
206
+ // control npm's cmd shims, which break when node resolves to node.cmd.
207
+ // npm's cmd shims use `IF EXIST "%~dp0\node.exe"` to find the node binary.
208
+ const isNodeExe = cmd.name === 'node' && cmd.path.toLowerCase().endsWith('.exe');
203
209
  if (existsSync(exePath)) {
210
+ // Skip warning and re-linking when the existing node.exe already matches
211
+ // the target, otherwise every command that re-links node would spam the
212
+ // warning below on warm installs.
213
+ if (isNodeExe && await isSameFile(exePath, cmd.path)) {
214
+ return;
215
+ }
204
216
  globalWarn(`The target bin directory already contains an exe called ${cmd.name}, so removing ${exePath}`);
205
217
  await rimraf(exePath);
206
218
  }
207
- // node.exe must exist as a real executable, not a cmd-shim wrapper.
208
- // We could update our own cmd shims to support node.cmd, but we can't
209
- // control npm's cmd shims, which break when node resolves to node.cmd.
210
- // npm's cmd shims use `IF EXIST "%~dp0\node.exe"` to find the node binary.
211
- if (cmd.name === 'node' && cmd.path.toLowerCase().endsWith('.exe')) {
219
+ if (isNodeExe) {
212
220
  try {
213
221
  await fs.link(cmd.path, exePath);
214
222
  }
@@ -231,7 +239,7 @@ async function linkBin(cmd, binsDir, opts) {
231
239
  if (opts?.preferSymlinkedExecutables && !IS_WINDOWS && cmd.nodeExecPath == null) {
232
240
  try {
233
241
  await symlinkDir(cmd.path, externalBinPath);
234
- await fixBin(cmd.path, 0o755);
242
+ await ensureExecutable(cmd.path, 0o755);
235
243
  }
236
244
  catch (err) { // eslint-disable-line
237
245
  if (err.code !== 'ENOENT' && err.code !== 'EISDIR') {
@@ -280,7 +288,115 @@ async function linkBin(cmd, binsDir, opts) {
280
288
  // ensure that bin are executable and not containing
281
289
  // windows line-endings(CRLF) on the hashbang line
282
290
  if (EXECUTABLE_SHEBANG_SUPPORTED) {
283
- await fixBin(cmd.path, 0o755);
291
+ await ensureExecutable(cmd.path, 0o755);
292
+ }
293
+ }
294
+ // Reports whether two paths refer to the same file. A matching inode/device
295
+ // pair (read as BigInts to avoid the precision loss of NTFS 64-bit file IDs)
296
+ // proves a hard link cheaply. Whenever identity can't be established that way —
297
+ // because the inodes genuinely differ or because Windows reports an unreliable
298
+ // zero inode — we fall back to comparing the file contents after a quick size
299
+ // check, which also treats a byte-identical copy as the same file.
300
+ async function isSameFile(pathA, pathB) {
301
+ const [statA, statB] = await Promise.all([
302
+ fs.stat(pathA, { bigint: true }).catch(() => null),
303
+ fs.stat(pathB, { bigint: true }).catch(() => null),
304
+ ]);
305
+ if (statA == null || statB == null)
306
+ return false;
307
+ if (statA.ino && statB.ino && statA.ino === statB.ino && statA.dev === statB.dev) {
308
+ return true;
309
+ }
310
+ if (statA.size !== statB.size)
311
+ return false;
312
+ return haveEqualContents(pathA, pathB);
313
+ }
314
+ const FILE_COMPARE_CHUNK_SIZE = 64 * 1024;
315
+ // Compares two equally-sized files chunk by chunk, so an executable is never
316
+ // fully buffered in memory and a mismatch returns as early as possible.
317
+ async function haveEqualContents(pathA, pathB) {
318
+ const [fhA, fhB] = await Promise.all([
319
+ fs.open(pathA, 'r').catch(() => null),
320
+ fs.open(pathB, 'r').catch(() => null),
321
+ ]);
322
+ if (fhA == null || fhB == null) {
323
+ await fhA?.close().catch(() => { });
324
+ await fhB?.close().catch(() => { });
325
+ return false;
326
+ }
327
+ try {
328
+ const bufA = Buffer.alloc(FILE_COMPARE_CHUNK_SIZE);
329
+ const bufB = Buffer.alloc(FILE_COMPARE_CHUNK_SIZE);
330
+ let position = 0;
331
+ for (;;) {
332
+ // Reading sequentially is intentional: each iteration compares one chunk
333
+ // and stops early on a mismatch or EOF.
334
+ const [readA, readB] = await Promise.all([
335
+ fhA.read(bufA, 0, FILE_COMPARE_CHUNK_SIZE, position),
336
+ fhB.read(bufB, 0, FILE_COMPARE_CHUNK_SIZE, position),
337
+ ]);
338
+ if (readA.bytesRead !== readB.bytesRead)
339
+ return false;
340
+ if (readA.bytesRead === 0)
341
+ return true;
342
+ if (!bufA.subarray(0, readA.bytesRead).equals(bufB.subarray(0, readB.bytesRead))) {
343
+ return false;
344
+ }
345
+ position += readA.bytesRead;
346
+ }
347
+ }
348
+ catch {
349
+ // A transient read error must not abort bin linking: treat the files as
350
+ // different so the caller falls back to the warn + remove + relink path.
351
+ return false;
352
+ }
353
+ finally {
354
+ await fhA.close().catch(() => { });
355
+ await fhB.close().catch(() => { });
356
+ }
357
+ }
358
+ // `fixBin` chmods the bin's source file (which lives inside the store) to make
359
+ // it executable and rewrites a Windows CRLF shebang to LF. Under the global
360
+ // virtual store that source is `{storeDir}/links/...`, so on a read-only store
361
+ // (e.g. `frozenStore`) the chmod is refused — with EPERM/EACCES when the file is
362
+ // owned but permissions forbid it, or EROFS on a genuinely read-only filesystem
363
+ // (Nix store, RO bind mount, OCI layer). A complete seed already ships its bins
364
+ // executable and shebang-normalized by the writable seed-build, so that work is
365
+ // redundant: treat an already-correct target as a no-op, keeping bin-linking
366
+ // write-free (see building/during-install: "Bin-linking reuses existing symlinks
367
+ // write-free"). A non-executable bin — or one still carrying a CRLF shebang that
368
+ // `fixBin` could not rewrite here — still throws, because that means the seed is
369
+ // broken and the bin would not run.
370
+ async function ensureExecutable(file, mode) {
371
+ try {
372
+ await fixBin(file, mode);
373
+ }
374
+ catch (err) { // eslint-disable-line
375
+ if (err.code === 'EPERM' || err.code === 'EACCES' || err.code === 'EROFS') {
376
+ const stat = await fs.stat(file).catch(() => undefined);
377
+ if (stat != null && (stat.mode & 0o111) !== 0 && !(await hasWindowsShebang(file)))
378
+ return;
379
+ }
380
+ throw err;
381
+ }
382
+ }
383
+ // Detects a `#!`-shebang line terminated by CRLF, which fails to execute on
384
+ // POSIX. Mirrors bin-links' own fix-bin detection so a chmod failure on a
385
+ // read-only store is only swallowed when the bin is genuinely already correct.
386
+ async function hasWindowsShebang(file) {
387
+ const fh = await fs.open(file, 'r').catch(() => undefined);
388
+ if (fh == null)
389
+ return false;
390
+ try {
391
+ const buf = Buffer.alloc(2048);
392
+ await fh.read(buf, 0, 2048, 0);
393
+ return buf[0] === 0x23 /* # */ && buf[1] === 0x21 /* ! */ && /^#![^\n]+\r\n/.test(buf.toString());
394
+ }
395
+ catch {
396
+ return false;
397
+ }
398
+ finally {
399
+ await fh.close().catch(() => { });
284
400
  }
285
401
  }
286
402
  function getExeExtension() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pnpm/bins.linker",
3
- "version": "1100.0.13",
3
+ "version": "1100.0.15",
4
4
  "description": "Link bins to node_modules/.bin",
5
5
  "keywords": [
6
6
  "pnpm",
@@ -28,28 +28,28 @@
28
28
  "!*.map"
29
29
  ],
30
30
  "dependencies": {
31
- "@zkochan/cmd-shim": "^9.0.3",
31
+ "@zkochan/cmd-shim": "^9.0.6",
32
32
  "@zkochan/rimraf": "^4.0.0",
33
33
  "bin-links": "^6.0.2",
34
34
  "is-subdir": "^2.0.0",
35
35
  "is-windows": "^1.0.2",
36
36
  "normalize-path": "^3.0.0",
37
37
  "ramda": "npm:@pnpm/ramda@0.28.1",
38
- "semver": "^7.8.1",
38
+ "semver": "^7.8.4",
39
39
  "symlink-dir": "^10.0.1",
40
- "@pnpm/bins.resolver": "1100.0.7",
41
- "@pnpm/pkg-manifest.reader": "1100.0.7",
42
40
  "@pnpm/error": "1100.0.0",
41
+ "@pnpm/pkg-manifest.reader": "1100.0.8",
43
42
  "@pnpm/fs.read-modules-dir": "1100.0.1",
44
- "@pnpm/pkg-manifest.utils": "1100.2.4",
45
- "@pnpm/types": "1101.3.1",
46
- "@pnpm/workspace.project-manifest-reader": "1100.0.12"
43
+ "@pnpm/bins.resolver": "1100.0.8",
44
+ "@pnpm/pkg-manifest.utils": "1100.2.5",
45
+ "@pnpm/workspace.project-manifest-reader": "1100.0.13",
46
+ "@pnpm/types": "1101.3.2"
47
47
  },
48
48
  "peerDependencies": {
49
- "@pnpm/logger": "^1001.0.1"
49
+ "@pnpm/logger": "^1100.0.0"
50
50
  },
51
51
  "devDependencies": {
52
- "@jest/globals": "30.3.0",
52
+ "@jest/globals": "30.4.1",
53
53
  "@types/is-windows": "^1.0.2",
54
54
  "@types/node": "^22.19.19",
55
55
  "@types/normalize-path": "^3.0.2",
@@ -57,7 +57,7 @@
57
57
  "@types/semver": "7.7.1",
58
58
  "cmd-extension": "^2.0.0",
59
59
  "tempy": "3.0.0",
60
- "@pnpm/bins.linker": "1100.0.13",
60
+ "@pnpm/bins.linker": "1100.0.15",
61
61
  "@pnpm/logger": "1100.0.0",
62
62
  "@pnpm/test-fixtures": "1100.0.0"
63
63
  },