From 6089939e15eefd1baebb5c8c6fc80dcbdd6f2895 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9C=88=E6=AD=A3=E6=B5=B7=E8=A7=92?= <2443029279@qq.com> Date: Mon, 13 Oct 2025 21:45:59 +0800 Subject: [PATCH] fix: sync bin links after injected deps sync (#10064) * fix: sync bin links after injected deps sync * test: add integration test for bin sync after scripts * docs: add changeset for bin sync fix * style: apply Copilot's suggestion --- .changeset/sync-injected-deps-bins.md | 6 ++ pnpm-lock.yaml | 12 ++++ pnpm/test/syncInjectedDepsAfterScripts-bin.ts | 68 +++++++++++++++++++ workspace/injected-deps-syncer/package.json | 4 ++ workspace/injected-deps-syncer/src/index.ts | 57 ++++++++++++++++ workspace/injected-deps-syncer/tsconfig.json | 12 ++++ 6 files changed, 159 insertions(+) create mode 100644 .changeset/sync-injected-deps-bins.md create mode 100644 pnpm/test/syncInjectedDepsAfterScripts-bin.ts diff --git a/.changeset/sync-injected-deps-bins.md b/.changeset/sync-injected-deps-bins.md new file mode 100644 index 0000000000..0d8b614754 --- /dev/null +++ b/.changeset/sync-injected-deps-bins.md @@ -0,0 +1,6 @@ +--- +"@pnpm/workspace.injected-deps-syncer": patch +"pnpm": patch +--- + +Sync bin links after injected dependencies are updated by build scripts. This ensures that binaries created during build processes are properly linked and accessible to consuming projects [#10057](https://github.com/pnpm/pnpm/issues/10057). diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3462eac73a..570eaff2f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8719,9 +8719,21 @@ importers: '@pnpm/error': specifier: workspace:* version: link:../../packages/error + '@pnpm/link-bins': + specifier: workspace:* + version: link:../../pkg-manager/link-bins '@pnpm/modules-yaml': specifier: workspace:* version: link:../../pkg-manager/modules-yaml + '@pnpm/read-package-json': + specifier: workspace:* + version: link:../../pkg-manifest/read-package-json + '@pnpm/types': + specifier: workspace:* + version: link:../../packages/types + '@pnpm/workspace.find-packages': + specifier: workspace:* + version: link:../find-packages '@types/normalize-path': specifier: 'catalog:' version: 3.0.2 diff --git a/pnpm/test/syncInjectedDepsAfterScripts-bin.ts b/pnpm/test/syncInjectedDepsAfterScripts-bin.ts new file mode 100644 index 0000000000..0b58e538e2 --- /dev/null +++ b/pnpm/test/syncInjectedDepsAfterScripts-bin.ts @@ -0,0 +1,68 @@ +import fs from 'fs' +import { preparePackages } from '@pnpm/prepare' +import { sync as writeYamlFile } from 'write-yaml-file' +import { execPnpm } from './utils/index.js' + +test('sync bin links after build script', async () => { + preparePackages([ + { + name: 'cli-tool', + version: '1.0.0', + bin: { + 'cli-tool': 'bin/cli.js', + }, + scripts: { + build: 'node -e "const fs = require(\'fs\'); fs.mkdirSync(\'bin\', { recursive: true }); fs.writeFileSync(\'bin/cli.js\', \'#!/usr/bin/env node\\nconsole.log(\\\'CLI tool works!\\\')\\n\', \'utf-8\')"', + }, + }, + { + name: 'consumer', + version: '1.0.0', + dependencies: { + 'cli-tool': 'workspace:*', + }, + dependenciesMeta: { + 'cli-tool': { + injected: true, + }, + }, + scripts: { + test: 'cli-tool', + }, + }, + ]) + + writeYamlFile('pnpm-workspace.yaml', { + packages: ['*'], + }) + + fs.writeFileSync('.npmrc', [ + 'reporter=append-only', + 'inject-workspace-packages=true', + 'dedupe-injected-deps=false', + 'sync-injected-deps-after-scripts[]=build', + ].join('\n')) + + // Install - bin won't be created because bin/cli.js doesn't exist yet + await execPnpm(['install']) + + // Verify injection happened + expect(fs.readdirSync('node_modules/.pnpm')).toContain('cli-tool@file+cli-tool') + + // Build cli-tool + await execPnpm(['--filter=cli-tool', 'run', 'build']) + + // Verify bin/cli.js was created + expect(fs.existsSync('cli-tool/bin/cli.js')).toBe(true) + + // Verify bin was synced to the injected location + const injectedBinPath = 'node_modules/.pnpm/cli-tool@file+cli-tool/node_modules/cli-tool/bin/cli.js' + expect(fs.existsSync(injectedBinPath)).toBe(true) + + // Verify bin link was created + const binPath = 'node_modules/.pnpm/cli-tool@file+cli-tool/node_modules/.bin/cli-tool' + expect(fs.existsSync(binPath) || fs.existsSync(`${binPath}.CMD`) || fs.existsSync(`${binPath}.ps1`)).toBe(true) + + // Run the consumer's test script which uses the bin + await execPnpm(['--filter=consumer', 'test']) +}) diff --git a/workspace/injected-deps-syncer/package.json b/workspace/injected-deps-syncer/package.json index 1af0fce8c2..a0788cb781 100644 --- a/workspace/injected-deps-syncer/package.json +++ b/workspace/injected-deps-syncer/package.json @@ -33,7 +33,11 @@ "dependencies": { "@pnpm/directory-fetcher": "workspace:*", "@pnpm/error": "workspace:*", + "@pnpm/link-bins": "workspace:*", "@pnpm/modules-yaml": "workspace:*", + "@pnpm/read-package-json": "workspace:*", + "@pnpm/types": "workspace:*", + "@pnpm/workspace.find-packages": "workspace:*", "@types/normalize-path": "catalog:", "normalize-path": "catalog:" }, diff --git a/workspace/injected-deps-syncer/src/index.ts b/workspace/injected-deps-syncer/src/index.ts index bf0c4e7e35..d970416e83 100644 --- a/workspace/injected-deps-syncer/src/index.ts +++ b/workspace/injected-deps-syncer/src/index.ts @@ -1,7 +1,11 @@ import path from 'path' import { PnpmError } from '@pnpm/error' +import { linkBins, linkBinsOfPackages } from '@pnpm/link-bins' import { logger as createLogger } from '@pnpm/logger' import { readModulesManifest } from '@pnpm/modules-yaml' +import { safeReadPackageJsonFromDir } from '@pnpm/read-package-json' +import { type DependencyManifest } from '@pnpm/types' +import { findWorkspacePackagesNoCheck } from '@pnpm/workspace.find-packages' import normalizePath from 'normalize-path' import { DirPatcher } from './DirPatcher.js' @@ -57,4 +61,57 @@ export async function syncInjectedDeps (opts: SyncInjectedDepsOptions): Promise< targetDirs.map(targetDir => path.resolve(opts.workspaceDir!, targetDir)) ) await Promise.all(patchers.map(patcher => patcher.apply())) + + // After syncing files, also sync bin links if the package has binaries + await syncBinLinks(pkgRootDir, targetDirs, opts.workspaceDir) +} + +async function syncBinLinks ( + pkgRootDir: string, + targetDirs: string[], + workspaceDir: string +): Promise { + const manifest = await safeReadPackageJsonFromDir(pkgRootDir) as DependencyManifest | undefined + + if (!manifest?.bin || !manifest?.name) { + return + } + + // Step 1: Link bins in .pnpm virtual store + const binLinkPromises = targetDirs.map(async (targetDir) => { + const resolvedTargetDir = path.resolve(workspaceDir, targetDir) + const parentNodeModulesDir = path.dirname(resolvedTargetDir) + const binDir = path.join(parentNodeModulesDir, '.bin') + + await linkBinsOfPackages( + [{ + manifest, + location: resolvedTargetDir, + }], + binDir, + {} + ) + }) + + // Step 2: Relink bins for all workspace projects + // We need to relink bins for all workspace projects because injected deps + // can be used by any project in the workspace. We relink all bins (not just + // this package) to ensure consistency. + const allProjects = await findWorkspacePackagesNoCheck(workspaceDir, {}) + + const consumerLinkPromises = allProjects.map(async (project) => { + const projectNodeModules = path.join(project.rootDir, 'node_modules') + const projectBinDir = path.join(projectNodeModules, '.bin') + + // Relink all bins in the project's node_modules + await linkBins(projectNodeModules, projectBinDir, { + allowExoticManifests: true, + projectManifest: project.manifest, + warn: (msg: string) => { + console.warn(`[linkBins warning] ${msg}`) + }, + }) + }) + + await Promise.all([...binLinkPromises, ...consumerLinkPromises]) } diff --git a/workspace/injected-deps-syncer/tsconfig.json b/workspace/injected-deps-syncer/tsconfig.json index 97707ab0b3..c50fe2dce0 100644 --- a/workspace/injected-deps-syncer/tsconfig.json +++ b/workspace/injected-deps-syncer/tsconfig.json @@ -21,8 +21,20 @@ { "path": "../../packages/logger" }, + { + "path": "../../packages/types" + }, + { + "path": "../../pkg-manager/link-bins" + }, { "path": "../../pkg-manager/modules-yaml" + }, + { + "path": "../../pkg-manifest/read-package-json" + }, + { + "path": "../find-packages" } ] } \ No newline at end of file