From a17a80750d7ccddfed19d713f64aa7191c0a4671 Mon Sep 17 00:00:00 2001 From: Erick Zhao Date: Thu, 4 Sep 2025 15:37:14 -0700 Subject: [PATCH] feat: improve Yarn Berry support --- .../api/cli/spec/util/check-system.spec.ts | 21 ++++++ packages/api/cli/src/util/check-system.ts | 21 ++++++ packages/template/base/package.json | 1 + packages/template/base/src/BaseTemplate.ts | 8 +++ packages/template/base/tmpl/_yarnrc | 1 + .../core-utils/spec/package-manager.spec.ts | 56 +++++++++------ .../utils/core-utils/src/package-manager.ts | 68 +++++++++++++------ 7 files changed, 132 insertions(+), 44 deletions(-) create mode 100644 packages/template/base/tmpl/_yarnrc diff --git a/packages/api/cli/spec/util/check-system.spec.ts b/packages/api/cli/spec/util/check-system.spec.ts index dd839347b1..8d57d1731c 100644 --- a/packages/api/cli/spec/util/check-system.spec.ts +++ b/packages/api/cli/spec/util/check-system.spec.ts @@ -87,6 +87,27 @@ describe('checkPackageManager', () => { ); }); + it('should throw if using yarn without node-linker=node-modules', async () => { + vi.mocked(resolvePackageManager).mockResolvedValue({ + executable: 'yarn', + install: 'add', + dev: '--dev', + exact: '--exact', + }); + vi.mocked(spawnPackageManager).mockImplementation((_pm, args) => { + if (args?.join(' ') === 'config get nodeLinker') { + return Promise.resolve('isolated'); + } else if (args?.join(' ') === '--version') { + return Promise.resolve('4.1.0'); + } else { + throw new Error('Unexpected command'); + } + }); + await expect(checkPackageManager()).rejects.toThrow( + 'When using Yarn 2+, `nodeLinker` must be set to "node-modules". Run `yarn config set nodeLinker node-modules` to set this config value, or add it to your project\'s `.yarnrc` file.', + ); + }); + it.each(['hoist-pattern', 'public-hoist-pattern'])( 'should pass without validation if user has set %s in their pnpm config', async (cfg) => { diff --git a/packages/api/cli/src/util/check-system.ts b/packages/api/cli/src/util/check-system.ts index c5ba467a65..c63982bc83 100644 --- a/packages/api/cli/src/util/check-system.ts +++ b/packages/api/cli/src/util/check-system.ts @@ -68,6 +68,25 @@ async function checkPnpmConfig() { } } +async function checkYarnConfig() { + const { yarn } = PACKAGE_MANAGERS; + const yarnVersion = await spawnPackageManager(yarn, ['--version']); + const nodeLinker = await spawnPackageManager(yarn, [ + 'config', + 'get', + 'nodeLinker', + ]); + if ( + yarnVersion && + semver.gte(yarnVersion, '2.0.0') && + nodeLinker !== 'node-modules' + ) { + throw new Error( + 'When using Yarn 2+, `nodeLinker` must be set to "node-modules". Run `yarn config set nodeLinker node-modules` to set this config value, or add it to your project\'s `.yarnrc` file.', + ); + } +} + // TODO(erickzhao): Drop antiquated versions of npm for Forge v8 const ALLOWLISTED_VERSIONS: Record< SupportedPackageManager, @@ -108,6 +127,8 @@ export async function checkPackageManager() { if (pm.executable === 'pnpm') { await checkPnpmConfig(); + } else if (pm.executable === 'yarn') { + await checkYarnConfig(); } return `${pm.executable}@${versionString}`; diff --git a/packages/template/base/package.json b/packages/template/base/package.json index 1b0031a298..1f88525c58 100644 --- a/packages/template/base/package.json +++ b/packages/template/base/package.json @@ -16,6 +16,7 @@ "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.3.1", "fs-extra": "^10.0.0", + "semver": "^7.2.1", "username": "^5.1.0" }, "devDependencies": { diff --git a/packages/template/base/src/BaseTemplate.ts b/packages/template/base/src/BaseTemplate.ts index ace88f3eb5..8783b5246b 100644 --- a/packages/template/base/src/BaseTemplate.ts +++ b/packages/template/base/src/BaseTemplate.ts @@ -8,6 +8,7 @@ import { } from '@electron-forge/shared-types'; import debug from 'debug'; import fs from 'fs-extra'; +import semver from 'semver'; import determineAuthor from './determine-author'; @@ -71,6 +72,13 @@ export class BaseTemplate implements ForgeTemplate { if (pm.executable === 'pnpm') { rootFiles.push('_npmrc'); + } else if ( + // Support Yarn 2+ by default by initializing with nodeLinker: node-modules + pm.executable === 'yarn' && + pm.version && + semver.gte(pm.version, '2.0.0') + ) { + rootFiles.push('_yarnrc'); } if (copyCIFiles) { diff --git a/packages/template/base/tmpl/_yarnrc b/packages/template/base/tmpl/_yarnrc new file mode 100644 index 0000000000..8b757b29a1 --- /dev/null +++ b/packages/template/base/tmpl/_yarnrc @@ -0,0 +1 @@ +nodeLinker: node-modules \ No newline at end of file diff --git a/packages/utils/core-utils/spec/package-manager.spec.ts b/packages/utils/core-utils/spec/package-manager.spec.ts index 00126863d6..408ee7ae6d 100644 --- a/packages/utils/core-utils/spec/package-manager.spec.ts +++ b/packages/utils/core-utils/spec/package-manager.spec.ts @@ -17,15 +17,14 @@ vi.mock('find-up', async (importOriginal) => { }); describe('package-manager', () => { - describe('npm_config_user_agent', () => { - beforeAll(() => { - const originalUa = process.env.npm_config_user_agent; - - return () => { - process.env.npm_config_user_agent = originalUa; - }; - }); + beforeAll(() => { + const originalUa = process.env.npm_config_user_agent; + return () => { + process.env.npm_config_user_agent = originalUa; + }; + }); + describe('npm_config_user_agent', () => { it.each([ { ua: 'yarn/1.22.22 npm/? node/v22.13.0 darwin arm64', @@ -112,16 +111,22 @@ describe('package-manager', () => { 'should return $pm if NODE_INSTALLER=$pm', async ({ pm }) => { process.env.NODE_INSTALLER = pm; + vi.mocked(spawn).mockResolvedValue('9.9.9'); await expect(resolvePackageManager()).resolves.toHaveProperty( 'executable', pm, ); + await expect(resolvePackageManager()).resolves.toHaveProperty( + 'version', + '9.9.9', + ); }, ); it('should return npm if package manager is unsupported', async () => { process.env.NODE_INSTALLER = 'bun'; console.warn = vi.fn(); + vi.mocked(spawn).mockResolvedValue('1.22.22'); await expect(resolvePackageManager()).resolves.toHaveProperty( 'executable', 'npm', @@ -133,21 +138,10 @@ describe('package-manager', () => { }); }); - describe('spawnPackageManager', () => { - it('should trim the output', async () => { - vi.mocked(spawn).mockResolvedValue(' foo \n'); - const result = await spawnPackageManager({ - executable: 'npm', - install: 'install', - dev: '--save-dev', - exact: '--save-exact', - }); - expect(result).toBe('foo'); - }); - }); - it('should use the package manager for the nearest ancestor lockfile if detected', async () => { + delete process.env.npm_config_user_agent; vi.mocked(findUp).mockResolvedValue('/Users/foo/bar/yarn.lock'); + vi.mocked(spawn).mockResolvedValue('1.22.22'); await expect(resolvePackageManager()).resolves.toHaveProperty( 'executable', 'yarn', @@ -155,11 +149,29 @@ describe('package-manager', () => { }); it('should fall back to npm if no other strategy worked', async () => { - process.env.npm_config_user_agent = undefined; + delete process.env.npm_config_user_agent; vi.mocked(findUp).mockResolvedValue(undefined); + vi.mocked(spawn).mockResolvedValue('9.99.99'); await expect(resolvePackageManager()).resolves.toHaveProperty( 'executable', 'npm', ); + await expect(resolvePackageManager()).resolves.toHaveProperty( + 'version', + '9.99.99', + ); + }); + + describe('spawnPackageManager', () => { + it('should trim the output', async () => { + vi.mocked(spawn).mockResolvedValue(' foo \n'); + const result = await spawnPackageManager({ + executable: 'npm', + install: 'install', + dev: '--save-dev', + exact: '--save-exact', + }); + expect(result).toBe('foo'); + }); }); }); diff --git a/packages/utils/core-utils/src/package-manager.ts b/packages/utils/core-utils/src/package-manager.ts index 5bbeff83e2..660ccbf916 100644 --- a/packages/utils/core-utils/src/package-manager.ts +++ b/packages/utils/core-utils/src/package-manager.ts @@ -96,18 +96,43 @@ export const resolvePackageManager: () => Promise = async () => { const lockfileName = path.basename(lockfile); lockfilePM = PM_FROM_LOCKFILE[lockfileName]; } - const installer = - process.env.NODE_INSTALLER || executingPM?.name || lockfilePM; - // TODO(erickzhao): Remove NODE_INSTALLER environment variable for Forge 8 - if (typeof process.env.NODE_INSTALLER === 'string' && !hasWarned) { - console.warn( - logSymbols.warning, - chalk.yellow( - `The NODE_INSTALLER environment variable is deprecated and will be removed in Electron Forge v8`, - ), + let installer; + let installerVersion; + + if (typeof process.env.NODE_INSTALLER === 'string') { + if (Object.keys(PACKAGE_MANAGERS).includes(process.env.NODE_INSTALLER)) { + installer = process.env.NODE_INSTALLER; + installerVersion = await spawnPackageManager( + PACKAGE_MANAGERS[installer as SupportedPackageManager], + ['--version'], + ); + if (!hasWarned) { + console.warn( + logSymbols.warning, + chalk.yellow( + `The NODE_INSTALLER environment variable is deprecated and will be removed in Electron Forge v8`, + ), + ); + hasWarned = true; + } + } else { + console.warn( + logSymbols.warning, + chalk.yellow( + `Package manager ${chalk.red(process.env.NODE_INSTALLER)} is unsupported. Falling back to ${chalk.green('npm')} instead.`, + ), + ); + } + } else if (executingPM) { + installer = executingPM.name; + installerVersion = executingPM.version; + } else if (lockfilePM) { + installer = lockfilePM; + installerVersion = await spawnPackageManager( + PACKAGE_MANAGERS[installer as SupportedPackageManager], + ['--version'], ); - hasWarned = true; } switch (installer) { @@ -117,19 +142,18 @@ export const resolvePackageManager: () => Promise = async () => { d( `Resolved package manager to ${installer}. (Derived from NODE_INSTALLER: ${process.env.NODE_INSTALLER}, npm_config_user_agent: ${process.env.npm_config_user_agent}, lockfile: ${lockfilePM})`, ); - return { ...PACKAGE_MANAGERS[installer], version: executingPM?.version }; + return { + ...PACKAGE_MANAGERS[installer], + version: installerVersion, + }; default: - if (installer !== undefined) { - console.warn( - logSymbols.warning, - chalk.yellow( - `Package manager ${chalk.red(installer)} is unsupported. Falling back to ${chalk.green('npm')} instead.`, - ), - ); - } else { - d(`No package manager detected. Falling back to npm.`); - } - return PACKAGE_MANAGERS['npm']; + d(`No valid package manager detected. Falling back to npm.`); + return { + ...PACKAGE_MANAGERS['npm'], + version: await spawnPackageManager(PACKAGE_MANAGERS['npm'], [ + '--version', + ]), + }; } };