diff --git a/.circleci/config.yml b/.circleci/config.yml index 648d6723cf..caf86e1166 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -46,7 +46,7 @@ commands: - run: name: 'Run fast tests' command: | - yarn test:fast --reporter=junit --outputFile="./reports/out/test_output.xml" + yarn test:fast --reporter=default --reporter=junit --outputFile="./reports/out/test_output.xml" run-slow-tests: steps: @@ -57,7 +57,7 @@ commands: - run: name: 'Run slow tests' command: | - yarn test:slow --reporter=junit --outputFile="./reports/out/test_output.xml" + yarn test:slow --reporter=default --reporter=junit --outputFile="./reports/out/test_output.xml" jobs: lint-and-build: @@ -110,6 +110,10 @@ jobs: libgtk-3-0 \ libgbm1 sudo add-apt-repository -y ppa:alexlarsson/flatpak + - run: + name: 'Install pnpm' + command: | + npm install -g pnpm@10.0.0 - run-fast-tests - store_test_results: path: ./reports/ @@ -155,6 +159,10 @@ jobs: libgdk-pixbuf2.0-dev \ libgtk-3-0 \ libgbm1 + - run: + name: 'Install pnpm' + command: | + npm install -g pnpm@10.0.0 - run-slow-tests - run: when: always # the report is generated on pass or fail diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000000..18d60a49d1 --- /dev/null +++ b/.npmrc @@ -0,0 +1,2 @@ +# Required to run pnpm in tests because `packageManager` is set to `yarn` in `package.json` +package-manager-strict=false diff --git a/package.json b/package.json index f1396f5fbf..c29173d4dc 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,6 @@ "cross-spawn": "^7.0.3", "cross-zip": "^4.0.0", "debug": "^4.3.1", - "detect-package-manager": "^3.0.2", "express": "^4.17.1", "express-ws": "^5.0.2", "fast-glob": "^3.2.7", diff --git a/packages/api/cli/package.json b/packages/api/cli/package.json index a4fde83251..9a8e93e45c 100644 --- a/packages/api/cli/package.json +++ b/packages/api/cli/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "@electron-forge/core": "7.6.1", + "@electron-forge/core-utils": "7.6.1", "@electron-forge/shared-types": "7.6.1", "@electron/get": "^3.0.0", "chalk": "^4.0.0", diff --git a/packages/api/cli/spec/check-system.spec.ts b/packages/api/cli/spec/check-system.spec.ts index 6ec9a1d680..7cc30c3ea0 100644 --- a/packages/api/cli/spec/check-system.spec.ts +++ b/packages/api/cli/spec/check-system.spec.ts @@ -1,19 +1,121 @@ -import { describe, expect, it } from 'vitest'; +import { resolvePackageManager, spawnPackageManager } from '@electron-forge/core-utils'; +import { describe, expect, it, vi } from 'vitest'; -import { checkValidPackageManagerVersion } from '../src/util/check-system'; +import { checkPackageManager } from '../src/util/check-system'; -describe('check-system', () => { - describe('validPackageManagerVersion', () => { - it('should consider whitelisted versions to be valid', () => { - expect(() => checkValidPackageManagerVersion('NPM', '3.10.1', '^3.0.0')).not.toThrow(); +vi.mock(import('@electron-forge/core-utils'), async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + resolvePackageManager: vi.fn(), + spawnPackageManager: vi.fn(), + }; +}); + +describe('checkPackageManager', () => { + it('should consider allowlisted versions to be valid', async () => { + vi.mocked(resolvePackageManager).mockResolvedValue({ + executable: 'npm', + install: 'install', + dev: '--save-dev', + exact: '--save-exact', + }); + vi.mocked(spawnPackageManager).mockResolvedValue('10.9.2'); + await expect(checkPackageManager()).resolves.not.toThrow(); + }); + + it('rejects versions that are outside of the supported range', async () => { + vi.mocked(resolvePackageManager).mockResolvedValue({ + executable: 'yarn', + install: 'add', + dev: '--dev', + exact: '--exact', }); - it('should consider Yarn nightly versions to be invalid', () => { - expect(() => checkValidPackageManagerVersion('Yarn', '0.23.0-20170311.0515', '0.23.0')).toThrow(); + // yarn 0.x unsupported + vi.mocked(spawnPackageManager).mockResolvedValue('0.22.0'); + await expect(checkPackageManager()).rejects.toThrow(); + }); + + it('should consider Yarn nightly versions to be invalid', async () => { + vi.mocked(resolvePackageManager).mockResolvedValue({ + executable: 'yarn', + install: 'add', + dev: '--dev', + exact: '--exact', + }); + vi.mocked(spawnPackageManager).mockResolvedValue('0.23.0-20170311.0515'); + await expect(checkPackageManager()).rejects.toThrow(); + }); + + it('should consider invalid semver versions to be invalid', async () => { + vi.mocked(resolvePackageManager).mockResolvedValue({ + executable: 'yarn', + install: 'add', + dev: '--dev', + exact: '--exact', }); + vi.mocked(spawnPackageManager).mockResolvedValue('1.22'); + await expect(checkPackageManager()).rejects.toThrow(); + }); + + it('should throw if using pnpm without node-linker=hoisted or custom hoist-pattern', async () => { + vi.mocked(resolvePackageManager).mockResolvedValue({ + executable: 'pnpm', + install: 'add', + dev: '--dev', + exact: '--exact', + }); + vi.mocked(spawnPackageManager).mockImplementation((args) => { + if (args?.join(' ') === 'config get node-linker') { + return Promise.resolve('isolated'); + } else if (args?.join(' ') === 'config get hoist-pattern') { + return Promise.resolve('undefined'); + } else if (args?.join(' ') === 'config get public-hoist-pattern') { + return Promise.resolve('undefined'); + } else if (args?.join(' ') === '--version') { + return Promise.resolve('10.0.0'); + } else { + throw new Error('Unexpected command'); + } + }); + await expect(checkPackageManager()).rejects.toThrow( + 'When using pnpm, `node-linker` must be set to "hoisted" (or a custom `hoist-pattern` or `public-hoist-pattern` must be defined). Run `pnpm config set node-linker hoisted` to set this config value, or add it to your project\'s `.npmrc` file.' + ); + }); + + it.each(['hoist-pattern', 'public-hoist-pattern'])('should pass without validation if user has set %s in their pnpm config', async (cfg) => { + vi.mocked(resolvePackageManager).mockResolvedValue({ + executable: 'pnpm', + install: 'add', + dev: '--dev', + exact: '--exact', + }); + vi.mocked(spawnPackageManager).mockImplementation((args) => { + if (args?.join(' ') === 'config get node-linker') { + return Promise.resolve('isolated'); + } else if (args?.join(' ') === `config get ${cfg}`) { + return Promise.resolve('["*eslint*","*babel*"]'); + } else if (args?.join(' ') === '--version') { + return Promise.resolve('10.0.0'); + } else { + return Promise.resolve('undefined'); + } + }); + await expect(checkPackageManager()).resolves.not.toThrow(); + }); - it('should consider invalid semver versions to be invalid', () => { - expect(() => checkValidPackageManagerVersion('Yarn', '0.22', '0.22.0')).toThrow(); + // resolvePackageManager optionally returns a `version` if `npm_config_user_agent` was used to + // resolve the package manager being used. + it('should not shell out to child process if version was already parsed via npm_config_user_agent', async () => { + vi.mocked(resolvePackageManager).mockResolvedValue({ + executable: 'npm', + install: 'install', + dev: '--save-dev', + exact: '--save-exact', + version: '10.9.2', }); + await expect(checkPackageManager()).resolves.not.toThrow(); + expect(spawnPackageManager).not.toHaveBeenCalled(); }); }); diff --git a/packages/api/cli/src/util/check-system.ts b/packages/api/cli/src/util/check-system.ts index a07993e579..17de67482a 100644 --- a/packages/api/cli/src/util/check-system.ts +++ b/packages/api/cli/src/util/check-system.ts @@ -2,7 +2,7 @@ import { exec } from 'node:child_process'; import os from 'node:os'; import path from 'node:path'; -import { utils as forgeUtils } from '@electron-forge/core'; +import { resolvePackageManager, spawnPackageManager, SupportedPackageManager } from '@electron-forge/core-utils'; import { ForgeListrTask } from '@electron-forge/shared-types'; import debug from 'debug'; import fs from 'fs-extra'; @@ -27,43 +27,70 @@ async function checkNodeVersion() { return process.versions.node; } -const NPM_ALLOWLISTED_VERSIONS = { - all: '^3.0.0 || ^4.0.0 || ~5.1.0 || ~5.2.0 || >= 5.4.2', - darwin: '>= 5.4.0', - linux: '>= 5.4.0', -}; -const YARN_ALLOWLISTED_VERSIONS = { - all: '>= 1.0.0', -}; +/** + * Packaging an app with Electron Forge requires `node_modules` to be on disk. + * With `pnpm`, this can be done in a few different ways. + * + * `node-linker=hoisted` replicates the behaviour of npm and Yarn Classic, while + * users may choose to set `public-hoist-pattern` or `hoist-pattern` for advanced + * configuration purposes. + */ +async function checkPnpmConfig() { + const hoistPattern = await spawnPackageManager(['config', 'get', 'hoist-pattern']); + const publicHoistPattern = await spawnPackageManager(['config', 'get', 'public-hoist-pattern']); -export function checkValidPackageManagerVersion(packageManager: string, version: string, allowlistedVersions: string) { - if (!semver.valid(version)) { - d(`Invalid semver-string while checking version: ${version}`); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - throw new Error(`Could not check ${packageManager} version "${version}", assuming incompatible`); + if (hoistPattern !== 'undefined' || publicHoistPattern !== 'undefined') { + d( + `Custom hoist pattern detected ${JSON.stringify({ + hoistPattern, + publicHoistPattern, + })}, assuming that the user has configured pnpm to package dependencies.` + ); + return; } - if (!semver.satisfies(version, allowlistedVersions)) { - throw new Error(`Incompatible version of ${packageManager} detected "${version}", must be in range ${allowlistedVersions}`); + + const nodeLinker = await spawnPackageManager(['config', 'get', 'node-linker']); + if (nodeLinker !== 'hoisted') { + throw new Error( + 'When using pnpm, `node-linker` must be set to "hoisted" (or a custom `hoist-pattern` or `public-hoist-pattern` must be defined). Run `pnpm config set node-linker hoisted` to set this config value, or add it to your project\'s `.npmrc` file.' + ); } } -function warnIfPackageManagerIsntAKnownGoodVersion(packageManager: string, version: string, allowlistedVersions: { [key: string]: string }) { - const osVersions = allowlistedVersions[process.platform]; - const versions = osVersions ? `${allowlistedVersions.all} || ${osVersions}` : allowlistedVersions.all; - const versionString = version.toString(); - checkValidPackageManagerVersion(packageManager, versionString, versions); -} +// TODO(erickzhao): Drop antiquated versions of npm for Forge v8 +const ALLOWLISTED_VERSIONS: Record> = { + npm: { + all: '^3.0.0 || ^4.0.0 || ~5.1.0 || ~5.2.0 || >= 5.4.2', + darwin: '>= 5.4.0', + linux: '>= 5.4.0', + }, + yarn: { + all: '>= 1.0.0', + }, + pnpm: { + all: '>= 8.0.0', + }, +}; -async function checkPackageManagerVersion() { - const version = await forgeUtils.yarnOrNpmSpawn(['--version']); +export async function checkPackageManager() { + const pm = await resolvePackageManager(); + const version = pm.version ?? (await spawnPackageManager(['--version'])); const versionString = version.toString().trim(); - if (await forgeUtils.hasYarn()) { - warnIfPackageManagerIsntAKnownGoodVersion('Yarn', versionString, YARN_ALLOWLISTED_VERSIONS); - return `yarn@${versionString}`; - } else { - warnIfPackageManagerIsntAKnownGoodVersion('NPM', versionString, NPM_ALLOWLISTED_VERSIONS); - return `npm@${versionString}`; + + const range = ALLOWLISTED_VERSIONS[pm.executable][process.platform] ?? ALLOWLISTED_VERSIONS[pm.executable].all; + if (!semver.valid(version)) { + d(`Invalid semver-string while checking version: ${version}`); + throw new Error(`Could not check ${pm.executable} version "${version}", assuming incompatible`); } + if (!semver.satisfies(version, range)) { + throw new Error(`Incompatible version of ${pm.executable} detected: "${version}" must be in range ${range}`); + } + + if (pm.executable === 'pnpm') { + await checkPnpmConfig(); + } + + return `${pm.executable}@${versionString}`; } /** @@ -106,9 +133,9 @@ export async function checkSystem(task: ForgeListrTask) { }, }, { - title: 'Checking packageManager version', + title: 'Checking package manager version', task: async (_, task) => { - const packageManager = await checkPackageManagerVersion(); + const packageManager = await checkPackageManager(); task.title = `Found ${packageManager}`; }, }, diff --git a/packages/api/core/package.json b/packages/api/core/package.json index b911dc5f6b..00d14e8f44 100644 --- a/packages/api/core/package.json +++ b/packages/api/core/package.json @@ -45,7 +45,6 @@ "@malept/cross-spawn-promise": "^2.0.0", "chalk": "^4.0.0", "debug": "^4.3.1", - "detect-package-manager": "^3.0.2", "fast-glob": "^3.2.7", "filenamify": "^4.1.0", "find-up": "^5.0.0", diff --git a/packages/api/core/spec/fast/util/install-dependencies.spec.ts b/packages/api/core/spec/fast/util/install-dependencies.spec.ts index c382321cd2..6a4ee1a91d 100644 --- a/packages/api/core/spec/fast/util/install-dependencies.spec.ts +++ b/packages/api/core/spec/fast/util/install-dependencies.spec.ts @@ -1,4 +1,4 @@ -import { hasYarn, yarnOrNpmSpawn } from '@electron-forge/core-utils'; +import { resolvePackageManager, spawnPackageManager } from '@electron-forge/core-utils'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import installDependencies, { DepType, DepVersionRestriction } from '../../../src/util/install-dependencies'; @@ -7,53 +7,57 @@ vi.mock(import('@electron-forge/core-utils'), async (importOriginal) => { const mod = await importOriginal(); return { ...mod, - hasYarn: vi.fn(), - yarnOrNpmSpawn: vi.fn(), + resolvePackageManager: vi.fn(), + spawnPackageManager: vi.fn(), }; }); describe('installDependencies', () => { it('should immediately resolve if no deps are provided', async () => { + vi.mocked(resolvePackageManager).mockResolvedValue({ executable: 'npm', install: 'install', dev: '--save-dev', exact: '--save-exact' }); await installDependencies('mydir', []); - expect(yarnOrNpmSpawn).not.toHaveBeenCalled(); + expect(spawnPackageManager).not.toHaveBeenCalled(); }); - it('should reject if reject the promise if exit code is not 0', async () => { - vi.mocked(yarnOrNpmSpawn).mockRejectedValueOnce('fail'); + it('should reject if the package manager fails to spawn', async () => { + vi.mocked(resolvePackageManager).mockResolvedValue({ executable: 'npm', install: 'install', dev: '--save-dev', exact: '--save-exact' }); + vi.mocked(spawnPackageManager).mockRejectedValueOnce('fail'); await expect(installDependencies('void', ['electron'])).rejects.toThrow('fail'); }); - it('should resolve if reject the promise if exit code is 0', async () => { - vi.mocked(yarnOrNpmSpawn).mockResolvedValueOnce('pass'); + it('should resolve if the package manager command succeeds', async () => { + vi.mocked(resolvePackageManager).mockResolvedValue({ executable: 'npm', install: 'install', dev: '--save-dev', exact: '--save-exact' }); + vi.mocked(spawnPackageManager).mockResolvedValueOnce('pass'); await expect(installDependencies('void', ['electron'])).resolves.toBe(undefined); }); describe.each([ - { pm: 'npm', install: 'install', flags: { exact: '--save-exact', dev: '--save-dev' } }, - { pm: 'yarn', install: 'add', flags: { exact: '--exact', dev: '--dev' } }, - ])('$pm', ({ pm, install, flags }) => { + { executable: 'npm' as const, install: 'install', exact: '--save-exact', dev: '--save-dev' }, + { executable: 'yarn' as const, install: 'add', exact: '--exact', dev: '--dev' }, + { executable: 'pnpm' as const, install: 'install', exact: '--save-exact', dev: '--save-dev' }, + ])('$executable', (args) => { beforeEach(() => { - vi.mocked(hasYarn).mockResolvedValue(pm === 'yarn'); + vi.mocked(resolvePackageManager).mockResolvedValue(args); }); it('should install deps', async () => { await installDependencies('mydir', ['react']); - expect(yarnOrNpmSpawn).toHaveBeenCalledWith([install, 'react'], expect.anything()); + expect(spawnPackageManager).toHaveBeenCalledWith([args.install, 'react'], expect.anything()); }); it('should install dev deps', async () => { await installDependencies('mydir', ['eslint'], DepType.DEV); - expect(yarnOrNpmSpawn).toHaveBeenCalledWith([install, 'eslint', flags.dev], expect.anything()); + expect(spawnPackageManager).toHaveBeenCalledWith([args.install, 'eslint', args.dev], expect.anything()); }); it('should install exact deps', async () => { await installDependencies('mydir', ['react'], DepType.PROD, DepVersionRestriction.EXACT); - expect(yarnOrNpmSpawn).toHaveBeenCalledWith([install, 'react', flags.exact], expect.anything()); + expect(spawnPackageManager).toHaveBeenCalledWith([args.install, 'react', args.exact], expect.anything()); }); it('should install exact dev deps', async () => { await installDependencies('mydir', ['eslint'], DepType.DEV, DepVersionRestriction.EXACT); - expect(yarnOrNpmSpawn).toHaveBeenCalledWith([install, 'eslint', flags.dev, flags.exact], expect.anything()); + expect(spawnPackageManager).toHaveBeenCalledWith([args.install, 'eslint', args.dev, args.exact], expect.anything()); }); }); }); diff --git a/packages/api/core/spec/slow/api.slow.spec.ts b/packages/api/core/spec/slow/api.slow.spec.ts index 972915c13c..ff78287366 100644 --- a/packages/api/core/spec/slow/api.slow.spec.ts +++ b/packages/api/core/spec/slow/api.slow.spec.ts @@ -3,7 +3,7 @@ import { execSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; -import { yarnOrNpmSpawn } from '@electron-forge/core-utils'; +import { spawnPackageManager } from '@electron-forge/core-utils'; import { createDefaultCertificate } from '@electron-forge/maker-appx'; import { ForgeConfig, IForgeResolvableMaker } from '@electron-forge/shared-types'; import { ensureTestDirIsNonexistent, expectLintToPass } from '@electron-forge/test-utils'; @@ -29,12 +29,21 @@ async function updatePackageJSON(dir: string, packageJSONUpdater: (packageJSON: await fs.promises.writeFile(path.resolve(dir, 'package.json'), JSON.stringify(packageJSON), 'utf-8'); } -describe.each([{ installer: 'npm' }, { installer: 'yarn' }])(`init (with $installer)`, ({ installer }) => { +describe.each([{ pm: 'npm' }, { pm: 'yarn' }, { pm: 'pnpm' }])(`init (with $pm)`, ({ pm }) => { let dir: string; beforeAll(async () => { - await yarnOrNpmSpawn(['run', 'link:prepare']); - process.env.NODE_INSTALLER = installer; + await spawnPackageManager(['run', 'link:prepare']); + process.env.NODE_INSTALLER = pm; + + if (pm === 'pnpm') { + await spawnPackageManager('config set node-linker hoisted'.split(' ')); + } + + return async () => { + await spawnPackageManager(['run', 'link:remove']); + delete process.env.NODE_INSTALLER; + }; }); const beforeInitTest = (params?: Partial, beforeInit?: BeforeInitFunction) => { @@ -50,6 +59,8 @@ describe.each([{ installer: 'npm' }, { installer: 'yarn' }])(`init (with $instal describe('init', () => { beforeInitTest(); + afterAll(() => fs.promises.rm(dir, { recursive: true, force: true })); + it('should fail in initializing an already initialized directory', async () => { await expect(api.init({ dir })).rejects.toThrow( `The specified path: "${dir}" is not empty. Please ensure it is empty before initializing a new project` @@ -76,8 +87,6 @@ describe.each([{ installer: 'npm' }, { installer: 'yarn' }])(`init (with $instal describe('lint', () => { it('should initially pass the linting process', () => expectLintToPass(dir)); }); - - afterAll(() => fs.promises.rm(dir, { recursive: true, force: true })); }); describe.skip('init with CI files enabled', () => { @@ -123,6 +132,10 @@ describe.each([{ installer: 'npm' }, { installer: 'yarn' }])(`init (with $instal describe('init (with a templater sans required Forge version)', () => { beforeAll(async () => { dir = await ensureTestDirIsNonexistent(); + + return async () => { + await fs.promises.rm(dir, { recursive: true, force: true }); + }; }); it('should fail in initializing', async () => { @@ -133,15 +146,15 @@ describe.each([{ installer: 'npm' }, { installer: 'yarn' }])(`init (with $instal }) ).rejects.toThrow(/it does not specify its required Forge version\.$/); }); - - afterAll(async () => { - await fs.promises.rm(dir, { recursive: true, force: true }); - }); }); describe('init (with a templater with a non-matching Forge version)', () => { beforeAll(async () => { dir = await ensureTestDirIsNonexistent(); + + return async () => { + await fs.promises.rm(dir, { recursive: true, force: true }); + }; }); it('should fail in initializing', async () => { @@ -152,15 +165,15 @@ describe.each([{ installer: 'npm' }, { installer: 'yarn' }])(`init (with $instal }) ).rejects.toThrow(/is not compatible with this version of Electron Forge/); }); - - afterAll(async () => { - await fs.promises.rm(dir, { recursive: true, force: true }); - }); }); describe('init (with a nonexistent templater)', () => { beforeAll(async () => { dir = await ensureTestDirIsNonexistent(); + + return async () => { + await fs.promises.rm(dir, { recursive: true, force: true }); + }; }); it('should fail in initializing', async () => { @@ -171,10 +184,6 @@ describe.each([{ installer: 'npm' }, { installer: 'yarn' }])(`init (with $instal }) ).rejects.toThrow('Failed to locate custom template'); }); - - afterAll(async () => { - await fs.promises.rm(dir, { recursive: true, force: true }); - }); }); describe('import', () => { @@ -184,9 +193,13 @@ describe.each([{ installer: 'npm' }, { installer: 'yarn' }])(`init (with $instal execSync(`git clone https://github.com/electron/electron-quick-start.git . --quiet`, { cwd: dir, }); + + return async () => { + await fs.promises.rm(dir, { recursive: true, force: true }); + }; }); - it('creates forge.config.js and is packageable', async () => { + it('creates forge.config.js and can successfully package the application', async () => { await updatePackageJSON(dir, async (packageJSON) => { packageJSON.name = 'Name'; packageJSON.productName = 'ProductName'; @@ -196,7 +209,7 @@ describe.each([{ installer: 'npm' }, { installer: 'yarn' }])(`init (with $instal expect(fs.existsSync(path.join(dir, 'forge.config.js'))).toEqual(true); - execSync(`${installer} install`, { + execSync(`${pm} install`, { cwd: dir, }); @@ -206,284 +219,270 @@ describe.each([{ installer: 'npm' }, { installer: 'yarn' }])(`init (with $instal expect(outDirContents).toHaveLength(1); expect(outDirContents[0]).toEqual(`ProductName-${process.platform}-${process.arch}`); }); - - afterAll(async () => { - await fs.promises.rm(dir, { recursive: true, force: true }); - }); - }); - - afterAll(async () => { - await yarnOrNpmSpawn(['run', 'link:remove']); }); -}); -describe('Electron Forge API', () => { - let dir: string; + describe('Electron Forge API', () => { + let dir: string; - beforeAll(async () => { - await yarnOrNpmSpawn(['run', 'link:prepare']); - }); + describe('after init', () => { + let devCert: string; - describe('after init', () => { - let devCert: string; + beforeAll(async () => { + dir = path.join(await ensureTestDirIsNonexistent(), 'electron-forge-test'); + await api.init({ dir }); - beforeAll(async () => { - dir = path.join(await ensureTestDirIsNonexistent(), 'electron-forge-test'); - await api.init({ dir }); + await updatePackageJSON(dir, async (packageJSON) => { + packageJSON.name = 'testapp'; + packageJSON.version = '1.0.0-beta.1'; + packageJSON.productName = 'Test-App'; + packageJSON.config = packageJSON.config || {}; + packageJSON.config.forge = { + ...packageJSON.config.forge, + packagerConfig: { + asar: false, + }, + }; + if (process.platform === 'win32') { + await fs.promises.copyFile(path.join(__dirname, '..', 'fixture', 'bogus-private-key.pvk'), path.join(dir, 'default.pvk')); + devCert = await createDefaultCertificate('CN=Test Author', { certFilePath: dir }); + } else if (process.platform === 'linux') { + packageJSON.config.forge.packagerConfig = { + ...packageJSON.config.forge.packagerConfig, + executableName: 'testapp', + }; + } + packageJSON.homepage = 'http://www.example.com/'; + packageJSON.author = 'Test Author'; + }); - await updatePackageJSON(dir, async (packageJSON) => { - packageJSON.name = 'testapp'; - packageJSON.version = '1.0.0-beta.1'; - packageJSON.productName = 'Test-App'; - packageJSON.config = packageJSON.config || {}; - packageJSON.config.forge = { - ...packageJSON.config.forge, - packagerConfig: { - asar: false, - }, + return async () => { + await fs.promises.rm(dir, { recursive: true, force: true }); }; - if (process.platform === 'win32') { - await fs.promises.rename(path.join(__dirname, '..', 'fixture', 'bogus-private-key.pvk'), path.join(dir, 'default.pvk')); - devCert = await createDefaultCertificate('CN=Test Author', { certFilePath: dir }); - } else if (process.platform === 'linux') { - packageJSON.config.forge.packagerConfig = { - ...packageJSON.config.forge.packagerConfig, - executableName: 'testapp', - }; - } - packageJSON.homepage = 'http://www.example.com/'; - packageJSON.author = 'Test Author'; }); - }); - it('throws an error when all is set', async () => { - await updatePackageJSON(dir, async (packageJSON) => { - assert(packageJSON.config.forge.packagerConfig); - packageJSON.config.forge.packagerConfig.all = true; - }); - await expect(api.package({ dir })).rejects.toThrow(/packagerConfig\.all is not supported by Electron Forge/); - await updatePackageJSON(dir, async (packageJSON) => { - assert(packageJSON.config.forge.packagerConfig); - delete packageJSON.config.forge.packagerConfig.all; + it('throws an error when all is set', async () => { + await updatePackageJSON(dir, async (packageJSON) => { + assert(packageJSON.config.forge.packagerConfig); + packageJSON.config.forge.packagerConfig.all = true; + }); + await expect(api.package({ dir })).rejects.toThrow(/packagerConfig\.all is not supported by Electron Forge/); + await updatePackageJSON(dir, async (packageJSON) => { + assert(packageJSON.config.forge.packagerConfig); + delete packageJSON.config.forge.packagerConfig.all; + }); }); - }); - it('can package to outDir without errors', async () => { - const outDir = `${dir}/foo`; + it('can package to outDir without errors', async () => { + const outDir = `${dir}/foo`; - expect(fs.existsSync(outDir)).toEqual(false); + expect(fs.existsSync(outDir)).toEqual(false); - await api.package({ dir, outDir }); + await api.package({ dir, outDir }); - expect(fs.existsSync(outDir)).toEqual(true); - }); - - it('can make from custom outDir without errors', async () => { - await updatePackageJSON(dir, async (packageJSON) => { - // eslint-disable-next-line n/no-missing-require - packageJSON.config.forge.makers = [{ name: require.resolve('@electron-forge/maker-zip') } as IForgeResolvableMaker]; + expect(fs.existsSync(outDir)).toEqual(true); }); - await api.make({ dir, skipPackage: true, outDir: `${dir}/foo` }); + it('can make from custom outDir without errors', async () => { + await updatePackageJSON(dir, async (packageJSON) => { + // eslint-disable-next-line n/no-missing-require + packageJSON.config.forge.makers = [{ name: require.resolve('@electron-forge/maker-zip') } as IForgeResolvableMaker]; + }); - // Cleanup here to ensure things dont break in the make tests - await fs.promises.rm(path.resolve(dir, 'foo'), { recursive: true, force: true }); - await fs.promises.rm(path.resolve(dir, 'out'), { recursive: true, force: true }); - }); + await api.make({ dir, skipPackage: true, outDir: `${dir}/foo` }); - describe('with prebuilt native module deps installed', () => { - beforeAll(async () => { - await installDeps(dir, ['ref-napi']); + // Cleanup here to ensure things dont break in the make tests + await fs.promises.rm(path.resolve(dir, 'foo'), { recursive: true, force: true }); + await fs.promises.rm(path.resolve(dir, 'out'), { recursive: true, force: true }); }); - it('can package without errors', async () => { - await api.package({ dir }); - }); + describe('with prebuilt native module deps installed', () => { + beforeAll(async () => { + await installDeps(dir, ['ref-napi']); - afterAll(async () => { - await fs.promises.rm(path.resolve(dir, 'node_modules/ref-napi'), { recursive: true, force: true }); - await updatePackageJSON(dir, async (packageJSON) => { - delete packageJSON.dependencies['ref-napi']; + return async () => { + await fs.promises.rm(path.resolve(dir, 'node_modules/ref-napi'), { recursive: true, force: true }); + await updatePackageJSON(dir, async (packageJSON) => { + delete packageJSON.dependencies['ref-napi']; + }); + }; }); - }); - }); - it('can package without errors', async () => { - await updatePackageJSON(dir, async (packageJSON) => { - assert(packageJSON.config.forge.packagerConfig); - packageJSON.config.forge.packagerConfig.asar = true; + it('can package without errors', async () => { + await api.package({ dir }); + }); }); - await api.package({ dir }); - }); - - describe('after package', () => { - it('should have deleted the forge config from the packaged app', async () => { - const cleanPackageJSON = await readMetadata({ - src: path.resolve(dir, 'out', `Test-App-${process.platform}-${process.arch}`), - logger: console.error, + it('can package without errors', async () => { + await updatePackageJSON(dir, async (packageJSON) => { + assert(packageJSON.config.forge.packagerConfig); + packageJSON.config.forge.packagerConfig.asar = true; }); - expect(cleanPackageJSON).not.toHaveProperty('config.forge'); - }); - it('should not affect the actual forge config', async () => { - const normalPackageJSON = await readRawPackageJson(dir); - expect(normalPackageJSON).toHaveProperty('config.forge'); + await api.package({ dir }); }); - if (process.platform !== 'win32') { - process.env.DISABLE_SQUIRREL_TEST = 'true'; - } + describe('after package', () => { + it('should have deleted the forge config from the packaged app', async () => { + const cleanPackageJSON = await readMetadata({ + src: path.resolve(dir, 'out', `Test-App-${process.platform}-${process.arch}`), + logger: console.error, + }); + expect(cleanPackageJSON).not.toHaveProperty('config.forge'); + }); - function getMakers(good: boolean) { - const allMakers = [ - '@electron-forge/maker-appx', - '@electron-forge/maker-deb', - '@electron-forge/maker-dmg', - '@electron-forge/maker-flatpak', - '@electron-forge/maker-rpm', - '@electron-forge/maker-snap', - '@electron-forge/maker-squirrel', - '@electron-forge/maker-wix', - '@electron-forge/maker-zip', - ]; - return allMakers - .map((maker) => require.resolve(maker)) - .filter((makerPath) => { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const MakerClass = require(makerPath).default; - const maker = new MakerClass(); - return maker.isSupportedOnCurrentPlatform() === good && maker.externalBinariesExist() === good; - }) - .map((makerPath) => () => { - const makerDefinition = { - name: makerPath, - platforms: [process.platform], - config: { - devCert, - }, - }; + it('should not affect the actual forge config', async () => { + const normalPackageJSON = await readRawPackageJson(dir); + expect(normalPackageJSON).toHaveProperty('config.forge'); + }); - if (process.platform === 'win32') { - (makerDefinition.config as Record).makeVersionWinStoreCompatible = true; - } + if (process.platform !== 'win32') { + process.env.DISABLE_SQUIRREL_TEST = 'true'; + } - return makerDefinition; - }); - } + function getMakers(good: boolean) { + const allMakers = [ + '@electron-forge/maker-appx', + '@electron-forge/maker-deb', + '@electron-forge/maker-dmg', + '@electron-forge/maker-flatpak', + '@electron-forge/maker-rpm', + '@electron-forge/maker-snap', + '@electron-forge/maker-squirrel', + '@electron-forge/maker-wix', + '@electron-forge/maker-zip', + ]; + return allMakers + .map((maker) => require.resolve(maker)) + .filter((makerPath) => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const MakerClass = require(makerPath).default; + const maker = new MakerClass(); + return maker.isSupportedOnCurrentPlatform() === good && maker.externalBinariesExist() === good; + }) + .map((makerPath) => () => { + const makerDefinition = { + name: makerPath, + platforms: [process.platform], + config: { + devCert, + }, + }; + + if (process.platform === 'win32') { + (makerDefinition.config as Record).makeVersionWinStoreCompatible = true; + } + + return makerDefinition; + }); + } - const goodMakers = getMakers(true); - const badMakers = getMakers(false); - - const testMakeTarget = function testMakeTarget( - target: () => { name: string }, - shouldPass: boolean, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - ...options: any[] - ) { - describe(`make (with target=${target().name})`, async () => { - beforeAll(async () => { - await updatePackageJSON(dir, async (packageJSON) => { - packageJSON.config.forge.makers = [target() as IForgeResolvableMaker]; + const goodMakers = getMakers(true); + const badMakers = getMakers(false); + + const testMakeTarget = function testMakeTarget( + target: () => { name: string }, + shouldPass: boolean, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...options: any[] + ) { + describe(`make (with target=${target().name})`, async () => { + beforeAll(async () => { + await updatePackageJSON(dir, async (packageJSON) => { + packageJSON.config.forge.makers = [target() as IForgeResolvableMaker]; + }); }); - }); - for (const optionsFetcher of options) { - if (shouldPass) { - it(`successfully makes for config: ${JSON.stringify(optionsFetcher())}`, async () => { - const outputs = await api.make(optionsFetcher()); - for (const outputResult of outputs) { - for (const output of outputResult.artifacts) { - expect(fs.existsSync(output)).toEqual(true); - expect(output.startsWith(path.resolve(dir, 'out', 'make'))).toEqual(true); + for (const optionsFetcher of options) { + if (shouldPass) { + it(`successfully makes for config: ${JSON.stringify(optionsFetcher())}`, async () => { + const outputs = await api.make(optionsFetcher()); + for (const outputResult of outputs) { + for (const output of outputResult.artifacts) { + expect(fs.existsSync(output)).toEqual(true); + expect(output.startsWith(path.resolve(dir, 'out', 'make'))).toEqual(true); + } } - } - }); - } else { - it(`fails for config: ${JSON.stringify(optionsFetcher())}`, async () => { - await expect(api.make(optionsFetcher())).rejects.toThrow(); - }); + }); + } else { + it(`fails for config: ${JSON.stringify(optionsFetcher())}`, async () => { + await expect(api.make(optionsFetcher())).rejects.toThrow(); + }); + } } - } - }); - }; + }); + }; - const targetOptionFetcher = () => ({ dir, skipPackage: true }); - for (const maker of goodMakers) { - testMakeTarget(maker, true, targetOptionFetcher); - } + const targetOptionFetcher = () => ({ dir, skipPackage: true }); + for (const maker of goodMakers) { + testMakeTarget(maker, true, targetOptionFetcher); + } - for (const maker of badMakers) { - testMakeTarget(maker, false, targetOptionFetcher); - } + for (const maker of badMakers) { + testMakeTarget(maker, false, targetOptionFetcher); + } - describe('make', () => { - it('throws an error when given an unrecognized platform', async () => { - await expect(api.make({ dir, platform: 'dos' })).rejects.toThrow(/invalid platform/); - }); + describe('make', () => { + it('throws an error when given an unrecognized platform', async () => { + await expect(api.make({ dir, platform: 'dos' })).rejects.toThrow(/invalid platform/); + }); - it("throws an error when the specified maker doesn't support the current platform", async () => { - const makerPath = path.resolve(__dirname, '../fixture/maker-unsupported'); - await expect( - api.make({ - dir, - overrideTargets: [ - { - name: makerPath, - } as IForgeResolvableMaker, - ], - skipPackage: true, - }) - ).rejects.toThrow(/the maker declared that it cannot run/); - }); + it("throws an error when the specified maker doesn't support the current platform", async () => { + const makerPath = path.resolve(__dirname, '../fixture/maker-unsupported'); + await expect( + api.make({ + dir, + overrideTargets: [ + { + name: makerPath, + } as IForgeResolvableMaker, + ], + skipPackage: true, + }) + ).rejects.toThrow(/the maker declared that it cannot run/); + }); - it("throws an error when the specified maker doesn't implement isSupportedOnCurrentPlatform()", async () => { - const makerPath = path.resolve(__dirname, '../fixture/maker-incompatible'); - await expect( - api.make({ - dir, - overrideTargets: [ - { - name: makerPath, - } as IForgeResolvableMaker, - ], - skipPackage: true, - }) - ).rejects.toThrow(/incompatible with this version/); - }); + it("throws an error when the specified maker doesn't implement isSupportedOnCurrentPlatform()", async () => { + const makerPath = path.resolve(__dirname, '../fixture/maker-incompatible'); + await expect( + api.make({ + dir, + overrideTargets: [ + { + name: makerPath, + } as IForgeResolvableMaker, + ], + skipPackage: true, + }) + ).rejects.toThrow(/incompatible with this version/); + }); - it('throws an error when no makers are configured for the given platform', async () => { - await expect( - api.make({ - dir, - overrideTargets: [ - { - name: path.resolve(__dirname, '../fixture/maker-wrong-platform'), - } as IForgeResolvableMaker, - ], - platform: 'linux', - skipPackage: true, - }) - ).rejects.toThrow('Could not find any make targets configured for the "linux" platform.'); - }); + it('throws an error when no makers are configured for the given platform', async () => { + await expect( + api.make({ + dir, + overrideTargets: [ + { + name: path.resolve(__dirname, '../fixture/maker-wrong-platform'), + } as IForgeResolvableMaker, + ], + platform: 'linux', + skipPackage: true, + }) + ).rejects.toThrow('Could not find any make targets configured for the "linux" platform.'); + }); - it.runIf(process.platform === 'darwin')('can make for the MAS platform successfully', async () => { - await expect( - api.make({ - dir, - // eslint-disable-next-line n/no-missing-require - overrideTargets: [require.resolve('@electron-forge/maker-zip'), require.resolve('@electron-forge/maker-dmg')], - platform: 'mas', - }) - ).resolves.toHaveLength(2); + it.runIf(process.platform === 'darwin')('can make for the MAS platform successfully', async () => { + await expect( + api.make({ + dir, + // eslint-disable-next-line n/no-missing-require + overrideTargets: [require.resolve('@electron-forge/maker-zip'), require.resolve('@electron-forge/maker-dmg')], + platform: 'mas', + }) + ).resolves.toHaveLength(2); + }); }); }); }); - - afterAll(() => fs.promises.rm(dir, { recursive: true, force: true })); - }); - - afterAll(async () => { - await yarnOrNpmSpawn(['link:remove']); }); }); diff --git a/packages/api/core/src/api/import.ts b/packages/api/core/src/api/import.ts index ed764e6404..321f3c2afb 100644 --- a/packages/api/core/src/api/import.ts +++ b/packages/api/core/src/api/import.ts @@ -1,6 +1,6 @@ import path from 'node:path'; -import { safeYarnOrNpm, updateElectronDependency } from '@electron-forge/core-utils'; +import { resolvePackageManager, updateElectronDependency } from '@electron-forge/core-utils'; import { ForgeListrOptions, ForgeListrTaskFn } from '@electron-forge/shared-types'; import baseTemplate from '@electron-forge/template-base'; import { autoTrace } from '@electron-forge/tracer'; @@ -193,7 +193,7 @@ export default autoTrace( { title: 'Installing dependencies', task: async (_, task) => { - const packageManager = await safeYarnOrNpm(); + const pm = await resolvePackageManager(); await writeChanges(); d('deleting old dependencies forcefully'); @@ -201,15 +201,15 @@ export default autoTrace( await fs.remove(path.resolve(dir, 'node_modules/.bin/electron.cmd')); d('installing dependencies'); - task.output = `${packageManager} install ${importDeps.join(' ')}`; + task.output = `${pm.executable} ${pm.install} ${importDeps.join(' ')}`; await installDepList(dir, importDeps); d('installing devDependencies'); - task.output = `${packageManager} install --dev ${importDevDeps.join(' ')}`; + task.output = `${pm.executable} ${pm.install} ${pm.dev} ${importDevDeps.join(' ')}`; await installDepList(dir, importDevDeps, DepType.DEV); - d('installing exactDevDependencies'); - task.output = `${packageManager} install --dev --exact ${importExactDevDeps.join(' ')}`; + d('installing devDependencies with exact versions'); + task.output = `${pm.executable} ${pm.install} ${pm.dev} ${pm.exact} ${importExactDevDeps.join(' ')}`; await installDepList(dir, importExactDevDeps, DepType.DEV, DepVersionRestriction.EXACT); }, }, diff --git a/packages/api/core/src/api/init-scripts/init-link.ts b/packages/api/core/src/api/init-scripts/init-link.ts index 93ebde4a7e..ec1e1805b2 100644 --- a/packages/api/core/src/api/init-scripts/init-link.ts +++ b/packages/api/core/src/api/init-scripts/init-link.ts @@ -1,6 +1,6 @@ import path from 'node:path'; -import { safeYarnOrNpm, yarnOrNpmSpawn } from '@electron-forge/core-utils'; +import { resolvePackageManager, spawnPackageManager } from '@electron-forge/core-utils'; import { ForgeListrTask } from '@electron-forge/shared-types'; import debug from 'debug'; @@ -22,12 +22,15 @@ export async function initLink(dir: string, task?: ForgeListrTask) { if (shouldLink) { d('Linking forge dependencies'); const packageJson = await readRawPackageJson(dir); - const packageManager = await safeYarnOrNpm(); + const pm = await resolvePackageManager(); + // TODO(erickzhao): the `--link-folder` argument only works for `yarn`. Since this command is + // only made for Forge contributors, it isn't a big deal if it doesn't work for other package managers, + // but we should make it cleaner. const linkFolder = path.resolve(__dirname, '..', '..', '..', '..', '..', '..', '.links'); for (const packageName of Object.keys(packageJson.devDependencies)) { if (packageName.startsWith('@electron-forge/')) { - if (task) task.output = `${packageManager} link --link-folder ${linkFolder} ${packageName}`; - await yarnOrNpmSpawn(['link', '--link-folder', linkFolder, packageName], { + if (task) task.output = `${pm.executable} link --link-folder ${linkFolder} ${packageName}`; + await spawnPackageManager(['link', '--link-folder', linkFolder, packageName], { cwd: dir, }); } diff --git a/packages/api/core/src/api/init-scripts/init-npm.ts b/packages/api/core/src/api/init-scripts/init-npm.ts index ecc63e8546..98f9f2e2f3 100644 --- a/packages/api/core/src/api/init-scripts/init-npm.ts +++ b/packages/api/core/src/api/init-scripts/init-npm.ts @@ -1,6 +1,6 @@ import path from 'node:path'; -import { safeYarnOrNpm } from '@electron-forge/core-utils'; +import { resolvePackageManager } from '@electron-forge/core-utils'; import { ForgeListrTask } from '@electron-forge/shared-types'; import debug from 'debug'; import fs from 'fs-extra'; @@ -29,17 +29,17 @@ export const exactDevDeps = ['electron']; export const initNPM = async (dir: string, task: ForgeListrTask): Promise => { d('installing dependencies'); - const packageManager = await safeYarnOrNpm(); - task.output = `${packageManager} install ${deps.join(' ')}`; + const pm = await resolvePackageManager(); + task.output = `${pm.executable} ${pm.install} ${deps.join(' ')}`; await installDepList(dir, deps); d('installing devDependencies'); - task.output = `${packageManager} install --dev ${deps.join(' ')}`; + task.output = `${pm.executable} ${pm.install} ${pm.dev} ${deps.join(' ')}`; await installDepList(dir, devDeps, DepType.DEV); d('installing exact devDependencies'); for (const packageName of exactDevDeps) { - task.output = `${packageManager} install --dev --exact ${packageName}`; + task.output = `${pm.executable} ${pm.install} ${pm.dev} ${pm.exact} ${packageName}`; await installDepList(dir, [packageName], DepType.DEV, DepVersionRestriction.EXACT); } }; diff --git a/packages/api/core/src/api/init.ts b/packages/api/core/src/api/init.ts index 29494e7d23..5d357295e6 100644 --- a/packages/api/core/src/api/init.ts +++ b/packages/api/core/src/api/init.ts @@ -1,6 +1,6 @@ import path from 'node:path'; -import { safeYarnOrNpm } from '@electron-forge/core-utils'; +import { resolvePackageManager } from '@electron-forge/core-utils'; import { ForgeTemplate } from '@electron-forge/shared-types'; import debug from 'debug'; import { Listr } from 'listr2'; @@ -56,7 +56,7 @@ async function validateTemplate(template: string, templateModule: ForgeTemplate) export default async ({ dir = process.cwd(), interactive = false, copyCIFiles = false, force = false, template = 'base' }: InitOptions): Promise => { d(`Initializing in: ${dir}`); - const packageManager = await safeYarnOrNpm(); + const pm = await resolvePackageManager(); const runner = new Listr<{ templateModule: ForgeTemplate; @@ -103,7 +103,7 @@ export default async ({ dir = process.cwd(), interactive = false, copyCIFiles = task: async (_, task) => { d('installing dependencies'); if (templateModule.dependencies?.length) { - task.output = `${packageManager} install ${templateModule.dependencies.join(' ')}`; + task.output = `${pm.executable} ${pm.install} ${pm.dev} ${templateModule.dependencies.join(' ')}`; } return await installDepList(dir, templateModule.dependencies || [], DepType.PROD, DepVersionRestriction.RANGE); }, @@ -114,7 +114,7 @@ export default async ({ dir = process.cwd(), interactive = false, copyCIFiles = task: async (_, task) => { d('installing devDependencies'); if (templateModule.devDependencies?.length) { - task.output = `${packageManager} install --dev ${templateModule.devDependencies.join(' ')}`; + task.output = `${pm.executable} ${pm.install} ${pm.dev} ${templateModule.devDependencies.join(' ')}`; } await installDepList(dir, templateModule.devDependencies || [], DepType.DEV); }, diff --git a/packages/api/core/src/util/index.ts b/packages/api/core/src/util/index.ts index c9d76afaec..b8f7752dcf 100644 --- a/packages/api/core/src/util/index.ts +++ b/packages/api/core/src/util/index.ts @@ -1,4 +1,4 @@ -import { getElectronVersion, hasYarn, yarnOrNpmSpawn } from '@electron-forge/core-utils'; +import { getElectronVersion, spawnPackageManager } from '@electron-forge/core-utils'; import { BuildIdentifierConfig, @@ -24,9 +24,7 @@ export default class ForgeUtils { getElectronVersion = getElectronVersion; - hasYarn = hasYarn; - - yarnOrNpmSpawn = yarnOrNpmSpawn; + spawnPackageManager = spawnPackageManager; /** * Register a virtual config file for forge to find. diff --git a/packages/api/core/src/util/install-dependencies.ts b/packages/api/core/src/util/install-dependencies.ts index ca616b10e0..77f08d73ce 100644 --- a/packages/api/core/src/util/install-dependencies.ts +++ b/packages/api/core/src/util/install-dependencies.ts @@ -1,4 +1,4 @@ -import { hasYarn, yarnOrNpmSpawn } from '@electron-forge/core-utils'; +import { resolvePackageManager, spawnPackageManager } from '@electron-forge/core-utils'; import { ExitError } from '@malept/cross-spawn-promise'; import debug from 'debug'; @@ -15,23 +15,19 @@ export enum DepVersionRestriction { } export default async (dir: string, deps: string[], depType = DepType.PROD, versionRestriction = DepVersionRestriction.RANGE): Promise => { - d('installing', JSON.stringify(deps), 'in:', dir, `depType=${depType},versionRestriction=${versionRestriction},withYarn=${await hasYarn()}`); + const pm = await resolvePackageManager(); + d('installing', JSON.stringify(deps), 'in:', dir, `depType=${depType},versionRestriction=${versionRestriction},withPackageManager=${pm.executable}`); if (deps.length === 0) { d('nothing to install, stopping immediately'); return Promise.resolve(); } - let cmd = ['install'].concat(deps); - if (await hasYarn()) { - cmd = ['add'].concat(deps); - if (depType === DepType.DEV) cmd.push('--dev'); - if (versionRestriction === DepVersionRestriction.EXACT) cmd.push('--exact'); - } else { - if (depType === DepType.DEV) cmd.push('--save-dev'); - if (versionRestriction === DepVersionRestriction.EXACT) cmd.push('--save-exact'); - } + const cmd = [pm.install].concat(deps); + if (depType === DepType.DEV) cmd.push(pm.dev); + if (versionRestriction === DepVersionRestriction.EXACT) cmd.push(pm.exact); + d('executing', JSON.stringify(cmd), 'in:', dir); try { - await yarnOrNpmSpawn(cmd, { + await spawnPackageManager(cmd, { cwd: dir, stdio: 'pipe', }); diff --git a/packages/template/base/package.json b/packages/template/base/package.json index 8a183f5195..831fa8c100 100644 --- a/packages/template/base/package.json +++ b/packages/template/base/package.json @@ -11,6 +11,7 @@ "node": ">= 16.4.0" }, "dependencies": { + "@electron-forge/core-utils": "7.6.1", "@electron-forge/shared-types": "7.6.1", "@malept/cross-spawn-promise": "^2.0.0", "debug": "^4.3.1", diff --git a/packages/template/base/src/BaseTemplate.ts b/packages/template/base/src/BaseTemplate.ts index a3428d30e7..78fc667b2c 100644 --- a/packages/template/base/src/BaseTemplate.ts +++ b/packages/template/base/src/BaseTemplate.ts @@ -1,5 +1,6 @@ import path from 'node:path'; +import { resolvePackageManager } from '@electron-forge/core-utils'; import { ForgeListrTaskDefinition, ForgeTemplate, InitTemplateOptions } from '@electron-forge/shared-types'; import debug from 'debug'; import fs from 'fs-extra'; @@ -56,12 +57,19 @@ export class BaseTemplate implements ForgeTemplate { { title: 'Copying starter files', task: async () => { + const pm = await resolvePackageManager(); d('creating directory:', path.resolve(directory, 'src')); await fs.mkdirs(path.resolve(directory, 'src')); const rootFiles = ['_gitignore', 'forge.config.js']; + + if (pm.executable === 'pnpm') { + rootFiles.push('.npmrc'); + } + if (copyCIFiles) { d(`Copying CI files is currently not supported - this will be updated in a later version of Forge`); } + const srcFiles = ['index.css', 'index.js', 'index.html', 'preload.js']; for (const file of rootFiles) { @@ -95,6 +103,15 @@ export class BaseTemplate implements ForgeTemplate { packageJSON.productName = packageJSON.name = path.basename(directory).toLowerCase(); packageJSON.author = await determineAuthor(directory); + const pm = await resolvePackageManager(); + + // As of pnpm v10, no postinstall scripts will run unless allowlisted through `onlyBuiltDependencies` + if (pm.executable === 'pnpm') { + packageJSON.pnpm = { + onlyBuiltDependencies: ['electron'], + }; + } + packageJSON.scripts.lint = 'echo "No linting configured"'; d('writing package.json to:', directory); diff --git a/packages/template/base/tmpl/.npmrc b/packages/template/base/tmpl/.npmrc new file mode 100644 index 0000000000..17633b501a --- /dev/null +++ b/packages/template/base/tmpl/.npmrc @@ -0,0 +1 @@ +node-linker = hoisted diff --git a/packages/template/vite-typescript/spec/ViteTypeScriptTemplate.slow.spec.ts b/packages/template/vite-typescript/spec/ViteTypeScriptTemplate.slow.spec.ts index da8912de7d..9dd6aaa27c 100644 --- a/packages/template/vite-typescript/spec/ViteTypeScriptTemplate.slow.spec.ts +++ b/packages/template/vite-typescript/spec/ViteTypeScriptTemplate.slow.spec.ts @@ -2,7 +2,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { yarnOrNpmSpawn } from '@electron-forge/core-utils'; +import { spawnPackageManager } from '@electron-forge/core-utils'; import testUtils from '@electron-forge/test-utils'; import glob from 'fast-glob'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; @@ -15,12 +15,12 @@ describe('ViteTypeScriptTemplate', () => { let dir: string; beforeAll(async () => { - await yarnOrNpmSpawn(['run', 'link:prepare']); + await spawnPackageManager(['run', 'link:prepare']); dir = await testUtils.ensureTestDirIsNonexistent(); }); afterAll(async () => { - await yarnOrNpmSpawn(['run', 'link:remove']); + await spawnPackageManager(['run', 'link:remove']); if (os.platform() !== 'win32') { // Windows platform `fs.remove(dir)` logic using `npm run test:clear`. await fs.promises.rm(dir, { force: true, recursive: true }); @@ -81,7 +81,7 @@ describe('ViteTypeScriptTemplate', () => { vite: `${require('../../../../node_modules/vite/package.json').version}`, }; await fs.promises.writeFile(path.resolve(dir, 'package.json'), JSON.stringify(pj)); - await yarnOrNpmSpawn(['install'], { + await spawnPackageManager(['install'], { cwd: dir, }); diff --git a/packages/template/webpack-typescript/spec/WebpackTypeScript.slow.spec.ts b/packages/template/webpack-typescript/spec/WebpackTypeScript.slow.spec.ts index 1ebb10b7dc..200dc029e4 100644 --- a/packages/template/webpack-typescript/spec/WebpackTypeScript.slow.spec.ts +++ b/packages/template/webpack-typescript/spec/WebpackTypeScript.slow.spec.ts @@ -1,7 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; -import { yarnOrNpmSpawn } from '@electron-forge/core-utils'; +import { spawnPackageManager } from '@electron-forge/core-utils'; import testUtils from '@electron-forge/test-utils'; import glob from 'fast-glob'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; @@ -14,7 +14,7 @@ describe('WebpackTypeScriptTemplate', () => { let dir: string; beforeAll(async () => { - await yarnOrNpmSpawn(['run', 'link:prepare']); + await spawnPackageManager(['run', 'link:prepare']); dir = await testUtils.ensureTestDirIsNonexistent(); }); @@ -71,7 +71,7 @@ describe('WebpackTypeScriptTemplate', () => { webpack: `${require('../../../../node_modules/webpack/package.json').version}`, }; await fs.promises.writeFile(path.resolve(dir, 'package.json'), JSON.stringify(pj)); - await yarnOrNpmSpawn(['install'], { + await spawnPackageManager(['install'], { cwd: dir, }); @@ -94,7 +94,7 @@ describe('WebpackTypeScriptTemplate', () => { }); afterAll(async () => { - await yarnOrNpmSpawn(['link:remove']); + await spawnPackageManager(['link:remove']); await fs.promises.rm(dir, { recursive: true, force: true }); }); }); diff --git a/packages/utils/core-utils/package.json b/packages/utils/core-utils/package.json index 693b872f45..10a2b3b27b 100644 --- a/packages/utils/core-utils/package.json +++ b/packages/utils/core-utils/package.json @@ -13,7 +13,6 @@ "@malept/cross-spawn-promise": "^2.0.0", "chalk": "^4.0.0", "debug": "^4.3.1", - "detect-package-manager": "^3.0.2", "find-up": "^5.0.0", "fs-extra": "^10.0.0", "log-symbols": "^4.0.0", diff --git a/packages/utils/core-utils/spec/electron-version.spec.ts b/packages/utils/core-utils/spec/electron-version.spec.ts index 1d3ac4bef6..f60fcb99d4 100644 --- a/packages/utils/core-utils/spec/electron-version.spec.ts +++ b/packages/utils/core-utils/spec/electron-version.spec.ts @@ -125,11 +125,11 @@ describe('getElectronModulePath', () => { describe('with npm workspaces', () => { beforeAll(() => { - process.env.NODE_INSTALLER = 'npm'; + process.env.npm_config_user_agent = 'npm/10.9.2 node/v22.13.0 darwin arm64 workspaces/false'; }); afterAll(() => { - delete process.env.NODE_INSTALLER; + delete process.env.npm_config_user_agent; }); it('finds the top-level electron module', async () => { diff --git a/packages/utils/core-utils/spec/package-manager.spec.ts b/packages/utils/core-utils/spec/package-manager.spec.ts new file mode 100644 index 0000000000..b0a02a772d --- /dev/null +++ b/packages/utils/core-utils/spec/package-manager.spec.ts @@ -0,0 +1,96 @@ +import findUp from 'find-up'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { resolvePackageManager } from '../src/package-manager'; + +vi.mock('find-up', async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + default: vi.fn(), + }; +}); + +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; + }; + }); + + it.each([ + { ua: 'yarn/1.22.22 npm/? node/v22.13.0 darwin arm64', pm: 'yarn', version: '1.22.22' }, + { ua: 'pnpm/10.0.0 npm/? node/v20.11.1 darwin arm64', pm: 'pnpm', version: '10.0.0' }, + { ua: 'npm/10.9.2 node/v22.13.0 darwin arm64 workspaces/false', pm: 'npm', version: '10.9.2' }, + ])('with $ua', async ({ ua, pm, version }) => { + process.env.npm_config_user_agent = ua; + await expect(resolvePackageManager()).resolves.toHaveProperty('executable', pm); + await expect(resolvePackageManager()).resolves.toHaveProperty('version', version); + }); + + it('should return yarn if npm_config_user_agent=yarn', async () => { + process.env.npm_config_user_agent = 'yarn/1.22.22 npm/? node/v22.13.0 darwin arm64'; + await expect(resolvePackageManager()).resolves.toHaveProperty('executable', 'yarn'); + await expect(resolvePackageManager()).resolves.toHaveProperty('version', '1.22.22'); + }); + + it('should return pnpm if npm_config_user_agent=pnpm', async () => { + process.env.npm_config_user_agent = 'pnpm/10.0.0 npm/? node/v20.11.1 darwin arm64'; + await expect(resolvePackageManager()).resolves.toHaveProperty('executable', 'pnpm'); + }); + + it('should return npm if npm_config_user_agent=npm', async () => { + process.env.npm_config_user_agent = 'npm/10.9.2 node/v22.13.0 darwin arm64 workspaces/false'; + await expect(resolvePackageManager()).resolves.toHaveProperty('executable', 'npm'); + }); + }); + + describe('NODE_INSTALLER', () => { + let initialNodeInstallerValue: string | undefined; + + beforeEach(() => { + initialNodeInstallerValue = process.env.NODE_INSTALLER; + delete process.env.NODE_INSTALLER; + // NODE_INSTALLER is deprecated for Electron Forge 8 and throws a console.warn that we want to silence in tests + vi.spyOn(console, 'warn').mockImplementation(() => undefined); + + return () => { + // For cleanup, we want to restore process.env.NODE_INSTALLER. + // If it wasn't explicitly set before, we delete the value set during the test. + // Otherwise, we restore the initial value. + if (!initialNodeInstallerValue) { + delete process.env.NODE_INSTALLER; + } else { + process.env.NODE_INSTALLER = initialNodeInstallerValue; + } + vi.restoreAllMocks(); + }; + }); + + it.each([{ pm: 'yarn' }, { pm: 'npm' }, { pm: 'pnpm' }])('should return $pm if NODE_INSTALLER=$pm', async ({ pm }) => { + process.env.NODE_INSTALLER = pm; + await expect(resolvePackageManager()).resolves.toHaveProperty('executable', pm); + }); + + it('should return npm if package manager is unsupported', async () => { + process.env.NODE_INSTALLER = 'bun'; + console.warn = vi.fn(); + await expect(resolvePackageManager()).resolves.toHaveProperty('executable', 'npm'); + expect(console.warn).toHaveBeenCalledWith('⚠', expect.stringContaining('Package manager bun is unsupported')); + }); + }); + + it('should use the package manager for the nearest ancestor lockfile if detected', async () => { + vi.mocked(findUp).mockResolvedValue('yarn.lock'); + await expect(resolvePackageManager()).resolves.toHaveProperty('executable', 'yarn'); + }); + + it('should fall back to npm if no other strategy worked', async () => { + process.env.npm_config_user_agent = undefined; + vi.mocked(findUp).mockResolvedValue(undefined); + await expect(resolvePackageManager()).resolves.toHaveProperty('executable', 'npm'); + }); +}); diff --git a/packages/utils/core-utils/spec/yarn-or-npm.spec.ts b/packages/utils/core-utils/spec/yarn-or-npm.spec.ts deleted file mode 100644 index ec02b9b1a8..0000000000 --- a/packages/utils/core-utils/spec/yarn-or-npm.spec.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { detect } from 'detect-package-manager'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { safeYarnOrNpm } from '../src/yarn-or-npm'; - -describe('yarn-or-npm', () => { - let nodeInstaller: string | undefined; - - beforeEach(() => { - nodeInstaller = process.env.NODE_INSTALLER; - delete process.env.NODE_INSTALLER; - }); - - afterEach(() => { - if (!nodeInstaller) { - delete process.env.NODE_INSTALLER; - } else { - process.env.NODE_INSTALLER = nodeInstaller; - } - }); - - it('should by default equal the system yarn-or-npm value', async () => { - const pm = await detect(); - await expect(safeYarnOrNpm()).resolves.toEqual(pm); - }); - - it('should return yarn if NODE_INSTALLER=yarn', async () => { - process.env.NODE_INSTALLER = 'yarn'; - await expect(safeYarnOrNpm()).resolves.toEqual('yarn'); - }); - - it('should return npm if NODE_INSTALLER=npm', async () => { - process.env.NODE_INSTALLER = 'npm'; - await expect(safeYarnOrNpm()).resolves.toEqual('npm'); - }); - - it('should return system value if NODE_INSTALLER is an unrecognized installer', async () => { - process.env.NODE_INSTALLER = 'magical_unicorn'; - console.warn = vi.fn(); - const pm = await detect(); - await expect(safeYarnOrNpm()).resolves.toEqual(pm); - expect(console.warn).toHaveBeenCalledWith('⚠', expect.stringContaining('Unknown NODE_INSTALLER')); - }); -}); diff --git a/packages/utils/core-utils/src/index.ts b/packages/utils/core-utils/src/index.ts index 2b4f1876ef..e5b67335f6 100644 --- a/packages/utils/core-utils/src/index.ts +++ b/packages/utils/core-utils/src/index.ts @@ -1,3 +1,3 @@ export * from './rebuild'; export * from './electron-version'; -export * from './yarn-or-npm'; +export * from './package-manager'; diff --git a/packages/utils/core-utils/src/package-manager.ts b/packages/utils/core-utils/src/package-manager.ts new file mode 100644 index 0000000000..a873a8a97c --- /dev/null +++ b/packages/utils/core-utils/src/package-manager.ts @@ -0,0 +1,104 @@ +import { CrossSpawnArgs, CrossSpawnOptions, spawn } from '@malept/cross-spawn-promise'; +import chalk from 'chalk'; +import debug from 'debug'; +import findUp from 'find-up'; +import logSymbols from 'log-symbols'; + +const d = debug('electron-forge:package-manager'); + +export type SupportedPackageManager = 'yarn' | 'npm' | 'pnpm'; +export type PMDetails = { executable: SupportedPackageManager; version?: string; install: string; dev: string; exact: string }; + +const MANAGERS: Record = { + yarn: { + executable: 'yarn', + install: 'add', + dev: '--dev', + exact: '--exact', + }, + npm: { + executable: 'npm', + install: 'install', + dev: '--save-dev', + exact: '--save-exact', + }, + pnpm: { + executable: 'pnpm', + install: 'add', + dev: '--save-dev', + exact: '--save-exact', + }, +}; + +const PM_FROM_LOCKFILE: Record = { + 'package-lock.json': 'npm', + 'yarn.lock': 'yarn', + 'pnpm-lock.yaml': 'pnpm', +}; + +/** + * Parses the `npm_config_user_agent` environment variable and returns its name and version. + * + * Code taken from {@link https://github.com/zkochan/packages/tree/main/which-pm-runs/ | which-pm-runs}. + */ +function pmFromUserAgent() { + const userAgent = process.env.npm_config_user_agent; + if (!userAgent) { + return undefined; + } + const pmSpec = userAgent.split(' ', 1)[0]; + const separatorPos = pmSpec.lastIndexOf('/'); + const name = pmSpec.substring(0, separatorPos); + return { + name: name === 'npminstall' ? 'cnpm' : name, + version: pmSpec.substring(separatorPos + 1), + }; +} + +/** + * Resolves the package manager to use. In order, it checks the following: + * + * 1. The value of the `NODE_INSTALLER` environment variable. + * 2. The `process.env.npm_config_user_agent` value set by the executing package manager. + * 3. The presence of a lockfile in an ancestor directory. + * 4. If an unknown package manager is used (or none of the above apply), then we fall back to `npm`. + * + * The version of the executing package manager is also returned if it is detected via user agent. + * + * Supported package managers are `yarn`, `pnpm`, and `npm`. + * + */ +export const resolvePackageManager: () => Promise = async () => { + const executingPM = pmFromUserAgent(); + const lockfile = await findUp(['package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'pnpm-workspace.yaml'], { type: 'file' }); + const lockfilePM = (lockfile && PM_FROM_LOCKFILE[lockfile]) ?? undefined; + 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') { + console.warn(logSymbols.warning, chalk.yellow(`The NODE_INSTALLER environment variable is deprecated and will be removed in Electron Forge v8`)); + } + + switch (installer) { + case 'yarn': + case 'npm': + case 'pnpm': + d( + `Resolved package manager to ${installer}. (Derived from NODE_INSTALLER: ${process.env.NODE_INSTALLER}, npm_config_user_agent: ${executingPM}, lockfile: ${lockfilePM}.)` + ); + return { ...MANAGERS[installer], version: executingPM?.version }; + 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 MANAGERS['npm']; + } +}; + +export const spawnPackageManager = async (args?: CrossSpawnArgs, opts?: CrossSpawnOptions): Promise => + spawn((await resolvePackageManager()).executable, args, opts); diff --git a/packages/utils/core-utils/src/yarn-or-npm.ts b/packages/utils/core-utils/src/yarn-or-npm.ts deleted file mode 100644 index ad8055e455..0000000000 --- a/packages/utils/core-utils/src/yarn-or-npm.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { CrossSpawnArgs, CrossSpawnOptions, spawn } from '@malept/cross-spawn-promise'; -import chalk from 'chalk'; -import { detect } from 'detect-package-manager'; -import logSymbols from 'log-symbols'; - -export const safeYarnOrNpm = async () => { - const system = await detect(); - switch (process.env.NODE_INSTALLER) { - case 'yarn': - case 'npm': - return process.env.NODE_INSTALLER; - default: - if (process.env.NODE_INSTALLER) { - console.warn(logSymbols.warning, chalk.yellow(`Unknown NODE_INSTALLER, using detected installer ${system}`)); - } - return system; - } -}; - -export const yarnOrNpmSpawn = async (args?: CrossSpawnArgs, opts?: CrossSpawnOptions): Promise => spawn(await safeYarnOrNpm(), args, opts); - -export const hasYarn = async () => (await safeYarnOrNpm()) === 'yarn'; diff --git a/yarn.lock b/yarn.lock index a9c013fc74..5c401ec82f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5322,13 +5322,6 @@ detect-node@^2.0.4: resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.1.0.tgz#c9c70775a49c3d03bc2c06d9a73be550f978f8b1" integrity sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g== -detect-package-manager@^3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/detect-package-manager/-/detect-package-manager-3.0.2.tgz#ca34261ab84198072580e93ae86582c575428da9" - integrity sha512-8JFjJHutStYrfWwzfretQoyNGoZVW1Fsrp4JO9spa7h/fBfwgTMEIy4/LBzRDGsxwVPHU0q+T9YvwLDJoOApLQ== - dependencies: - execa "^5.1.1" - dezalgo@^1.0.0: version "1.0.4" resolved "https://registry.yarnpkg.com/dezalgo/-/dezalgo-1.0.4.tgz#751235260469084c132157dfa857f386d4c33d81"