diff --git a/packages/api/cli/src/electron-forge-init.ts b/packages/api/cli/src/electron-forge-init.ts index 934dce4454..f25ac25739 100644 --- a/packages/api/cli/src/electron-forge-init.ts +++ b/packages/api/cli/src/electron-forge-init.ts @@ -29,6 +29,10 @@ program '--skip-git', 'Skip initializing a git repository in the initialized project.', ) + .option( + '--electron-version [version]', + 'Set a specific Electron version for your Forge project. Can take in a version string (e.g. `38.3.0`) or `latest`, `beta`, or `nightly` tags.', + ) .action(async (dir) => { const options = program.opts(); const tasks = new Listr( @@ -41,6 +45,7 @@ program initOpts.force = Boolean(options.force); initOpts.skipGit = Boolean(options.skipGit); initOpts.dir = resolveWorkingDir(dir, false); + initOpts.electronVersion = options.electronVersion ?? 'latest'; }, }, { @@ -117,6 +122,29 @@ program } initOpts.template = `${bundler}${language ? `-${language}` : ''}`; + + // TODO: add prompt for passing in an exact version as well + initOpts.electronVersion = await prompt.run>( + select, + { + message: 'Select an Electron release', + choices: [ + { + name: 'electron@latest', + value: 'latest', + }, + { + name: 'electron@beta', + value: 'beta', + }, + { + name: 'electron-nightly@latest', + value: 'nightly', + }, + ], + }, + ); + initOpts.skipGit = !(await prompt.run(confirm, { message: `Would you like to initialize Git in your new project?`, default: true, diff --git a/packages/api/core/spec/fast/init-git.spec.ts b/packages/api/core/spec/fast/init-scripts/init-git.spec.ts similarity index 97% rename from packages/api/core/spec/fast/init-git.spec.ts rename to packages/api/core/spec/fast/init-scripts/init-git.spec.ts index a20c3ec0e8..aefae37aad 100644 --- a/packages/api/core/spec/fast/init-git.spec.ts +++ b/packages/api/core/spec/fast/init-scripts/init-git.spec.ts @@ -5,7 +5,7 @@ import path from 'node:path'; import { beforeEach, describe, expect, it } from 'vitest'; -import { initGit } from '../../src/api/init-scripts/init-git'; +import { initGit } from '../../../src/api/init-scripts/init-git'; let dir: string; let dirID = Date.now(); diff --git a/packages/api/core/spec/fast/init-scripts/init-npm.spec.ts b/packages/api/core/spec/fast/init-scripts/init-npm.spec.ts new file mode 100644 index 0000000000..09aa5772f4 --- /dev/null +++ b/packages/api/core/spec/fast/init-scripts/init-npm.spec.ts @@ -0,0 +1,75 @@ +import { PACKAGE_MANAGERS } from '@electron-forge/core-utils'; +import { ForgeListrTask } from '@electron-forge/shared-types'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { deps, devDeps, initNPM } from '../../../src/api/init-scripts/init-npm'; +import { + DepType, + DepVersionRestriction, + installDependencies, +} from '../../../src/util/install-dependencies'; + +vi.mock('../../../src/util/install-dependencies', async (importOriginal) => ({ + ...(await importOriginal()), + installDependencies: vi.fn(), +})); + +describe('init-npm', () => { + const mockTask = { + output: '', + } as ForgeListrTask; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('with regular electron version', () => { + it('should call installDependencies three times with correct parameters', async () => { + const pm = PACKAGE_MANAGERS['npm']; + const dir = '/test/dir'; + + await initNPM(pm, dir, 'latest', mockTask); + expect(vi.mocked(installDependencies)).toHaveBeenNthCalledWith( + 1, + pm, + dir, + deps, + ); + expect(vi.mocked(installDependencies)).toHaveBeenNthCalledWith( + 2, + pm, + dir, + devDeps, + DepType.DEV, + ); + expect(vi.mocked(installDependencies)).toHaveBeenNthCalledWith( + 3, + pm, + dir, + ['electron@latest'], + DepType.DEV, + DepVersionRestriction.EXACT, + ); + }); + }); + + describe('with `nightly`', () => { + it('should install electron-nightly@latest instead of electron', async () => { + const pm = PACKAGE_MANAGERS['npm']; + const dir = '/test/dir'; + const electronVersion = 'nightly'; + + await initNPM(pm, dir, electronVersion, mockTask); + + expect(installDependencies).toHaveBeenCalledTimes(3); + expect(vi.mocked(installDependencies)).toHaveBeenNthCalledWith( + 3, + pm, + dir, + ['electron-nightly@latest'], + DepType.DEV, + DepVersionRestriction.EXACT, + ); + }); + }); +}); 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 fc029e7369..27cfe8f1e0 100644 --- a/packages/api/core/spec/fast/util/install-dependencies.spec.ts +++ b/packages/api/core/spec/fast/util/install-dependencies.spec.ts @@ -4,9 +4,10 @@ import { } from '@electron-forge/core-utils'; import { describe, expect, it, vi } from 'vitest'; -import installDependencies, { +import { DepType, DepVersionRestriction, + installDependencies, } from '../../../src/util/install-dependencies'; vi.mock(import('@electron-forge/core-utils'), async (importOriginal) => { diff --git a/packages/api/core/spec/slow/api.slow.spec.ts b/packages/api/core/spec/slow/api.slow.spec.ts index 5d1f82112a..352882e436 100644 --- a/packages/api/core/spec/slow/api.slow.spec.ts +++ b/packages/api/core/spec/slow/api.slow.spec.ts @@ -17,11 +17,11 @@ import { expectLintToPass, } from '@electron-forge/test-utils'; import { readMetadata } from 'electron-installer-common'; +import semver from 'semver'; import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; -// eslint-disable-next-line n/no-missing-import -import { api, InitOptions } from '../../src/api'; -import installDeps from '../../src/util/install-dependencies'; +import { api, InitOptions } from '../../src/api/index'; +import { installDependencies } from '../../src/util/install-dependencies'; import { readRawPackageJson } from '../../src/util/read-package-json'; type BeforeInitFunction = () => void; @@ -45,6 +45,68 @@ async function updatePackageJSON( ); } +// TODO: move more tests outside of the describe.each block +// if the actual package manager doesn't matter for the test +describe('init params', () => { + let dir: string; + describe('init (with electronVersion)', () => { + beforeEach(async () => { + dir = await ensureTestDirIsNonexistent(); + + return async () => { + await fs.promises.rm(dir, { recursive: true, force: true }); + }; + }); + + it('can define a specific Electron version with a version number', async () => { + await api.init({ + dir, + electronVersion: 'v38.0.0', + }); + const packageJSON = await import(path.resolve(dir, 'package.json')); + expect(packageJSON.devDependencies.electron).toEqual('38.0.0'); + }); + + it('can define a specific Electron nightly version with a version number', async () => { + await api.init({ + dir, + electronVersion: '40.0.0-nightly.20251020', + }); + const packageJSON = await import(path.resolve(dir, 'package.json')); + expect( + semver.valid(packageJSON.devDependencies['electron-nightly']), + ).not.toBeNull(); + expect(packageJSON.devDependencies.electron).not.toBeDefined(); + }); + + it('can define a specific Electron prerelease version with the beta tag', async () => { + await api.init({ + dir, + electronVersion: 'beta', + }); + const packageJSON = await import(path.resolve(dir, 'package.json')); + const prereleaseTag = semver.prerelease( + packageJSON.devDependencies.electron, + ); + expect(prereleaseTag).toEqual( + expect.arrayContaining([expect.stringMatching(/alpha|beta/)]), + ); + }); + + it('can define a specific Electron nightly version with the nightly tag', async () => { + await api.init({ + dir, + electronVersion: 'nightly', + }); + const packageJSON = await import(path.resolve(dir, 'package.json')); + expect( + semver.valid(packageJSON.devDependencies['electron-nightly']), + ).not.toBeNull(); + expect(packageJSON.devDependencies.electron).not.toBeDefined(); + }); + }); +}); + describe.each([ PACKAGE_MANAGERS['npm'], PACKAGE_MANAGERS['yarn'], @@ -400,7 +462,7 @@ describe.each([ describe('with prebuilt native module deps installed', () => { beforeAll(async () => { - await installDeps(pm, dir, ['ref-napi']); + await installDependencies(pm, dir, ['ref-napi']); return async () => { await fs.promises.rm(path.resolve(dir, 'node_modules/ref-napi'), { diff --git a/packages/api/core/spec/slow/install-dependencies.slow.spec.ts b/packages/api/core/spec/slow/install-dependencies.slow.spec.ts index ff10d8d4b6..0c518c53c9 100644 --- a/packages/api/core/spec/slow/install-dependencies.slow.spec.ts +++ b/packages/api/core/spec/slow/install-dependencies.slow.spec.ts @@ -5,7 +5,7 @@ import path from 'node:path'; import { PACKAGE_MANAGERS } from '@electron-forge/core-utils'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; -import installDeps from '../../src/util/install-dependencies'; +import { installDependencies } from '../../src/util/install-dependencies'; describe.runIf(!(process.platform === 'linux' && process.env.CI))( 'install-dependencies', @@ -19,7 +19,9 @@ describe.runIf(!(process.platform === 'linux' && process.env.CI))( }); it('should install the latest minor version when the dependency has a caret', async () => { - await installDeps(PACKAGE_MANAGERS['npm'], installDir, ['debug@^2.0.0']); + await installDependencies(PACKAGE_MANAGERS['npm'], installDir, [ + 'debug@^2.0.0', + ]); const packageJSON = await import( path.resolve(installDir, 'node_modules', 'debug', 'package.json') diff --git a/packages/api/core/src/api/import.ts b/packages/api/core/src/api/import.ts index 59f82f4cfa..997db79f13 100644 --- a/packages/api/core/src/api/import.ts +++ b/packages/api/core/src/api/import.ts @@ -17,9 +17,10 @@ import fs from 'fs-extra'; import { Listr } from 'listr2'; import { merge } from 'lodash'; -import installDepList, { +import { DepType, DepVersionRestriction, + installDependencies, } from '../util/install-dependencies'; import { readRawPackageJson } from '../util/read-package-json'; import upgradeForgeConfig, { @@ -306,15 +307,20 @@ export default autoTrace( d('installing dependencies'); task.output = `${pm.executable} ${pm.install} ${importDeps.join(' ')}`; - await installDepList(pm, dir, importDeps); + await installDependencies(pm, dir, importDeps); d('installing devDependencies'); task.output = `${pm.executable} ${pm.install} ${pm.dev} ${importDevDeps.join(' ')}`; - await installDepList(pm, dir, importDevDeps, DepType.DEV); + await installDependencies( + pm, + dir, + importDevDeps, + DepType.DEV, + ); d('installing devDependencies with exact versions'); task.output = `${pm.executable} ${pm.install} ${pm.dev} ${pm.exact} ${importExactDevDeps.join(' ')}`; - await installDepList( + await installDependencies( pm, dir, importExactDevDeps, 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 1c51f29274..442304d97b 100644 --- a/packages/api/core/src/api/init-scripts/init-npm.ts +++ b/packages/api/core/src/api/init-scripts/init-npm.ts @@ -4,10 +4,12 @@ import { PMDetails } from '@electron-forge/core-utils'; import { ForgeListrTask } from '@electron-forge/shared-types'; import debug from 'debug'; import fs from 'fs-extra'; +import semver from 'semver'; -import installDepList, { +import { DepType, DepVersionRestriction, + installDependencies, } from '../../util/install-dependencies'; const d = debug('electron-forge:init:npm'); @@ -35,23 +37,34 @@ export const exactDevDeps = ['electron']; export const initNPM = async ( pm: PMDetails, dir: string, + electronVersion: string, task: ForgeListrTask, ): Promise => { d('installing dependencies'); task.output = `${pm.executable} ${pm.install} ${deps.join(' ')}`; - await installDepList(pm, dir, deps); + await installDependencies(pm, dir, deps); - d('installing devDependencies'); - task.output = `${pm.executable} ${pm.install} ${pm.dev} ${deps.join(' ')}`; - await installDepList(pm, dir, devDeps, DepType.DEV); + d(`installing devDependencies`); + task.output = `${pm.executable} ${pm.install} ${pm.dev} ${devDeps.join(' ')}`; + await installDependencies(pm, dir, devDeps, DepType.DEV); d('installing exact devDependencies'); for (const packageName of exactDevDeps) { - task.output = `${pm.executable} ${pm.install} ${pm.dev} ${pm.exact} ${packageName}`; - await installDepList( + let packageInstallString = packageName; + if (packageName === 'electron') { + if (electronVersion === 'nightly') { + packageInstallString = `electron-nightly@latest`; + } else if (semver.prerelease(electronVersion)?.includes('nightly')) { + packageInstallString = `electron-nightly@${electronVersion}`; + } else { + packageInstallString += `@${electronVersion}`; + } + } + task.output = `${pm.executable} ${pm.install} ${pm.dev} ${pm.exact} ${packageInstallString}`; + await installDependencies( pm, dir, - [packageName], + [packageInstallString], DepType.DEV, DepVersionRestriction.EXACT, ); diff --git a/packages/api/core/src/api/init.ts b/packages/api/core/src/api/init.ts index b38917f15a..736b855b91 100644 --- a/packages/api/core/src/api/init.ts +++ b/packages/api/core/src/api/init.ts @@ -7,9 +7,10 @@ import debug from 'debug'; import { Listr } from 'listr2'; import semver from 'semver'; -import installDepList, { +import { DepType, DepVersionRestriction, + installDependencies, } from '../util/install-dependencies'; import { readRawPackageJson } from '../util/read-package-json'; @@ -46,6 +47,13 @@ export interface InitOptions { * By default, Forge initializes a git repository in the project directory. Set this option to `true` to skip this step. */ skipGit?: boolean; + /** + * Set a specific Electron version for your Forge project. + * Can take in version numbers or `latest`, `beta`, or `nightly`. + * + * @defaultValue The `latest` tag on npm. + */ + electronVersion?: string; } async function validateTemplate( @@ -75,12 +83,14 @@ export default async ({ force = false, template = 'base', skipGit = false, + electronVersion = 'latest', }: InitOptions): Promise => { d(`Initializing in: ${dir}`); const runner = new Listr<{ templateModule: ForgeTemplate; pm: PMDetails; + parsedElectronVersion: string; }>( [ { @@ -99,6 +109,32 @@ export default async ({ }, rendererOptions: { persistentOutput: true }, }, + { + title: `Resolving Electron version: ${chalk.cyan(electronVersion)}`, + task: async (ctx, task) => { + if ( + electronVersion === 'latest' || + electronVersion === 'beta' || + electronVersion === 'nightly' + ) { + task.output = `Using Electron version tag: ${chalk.cyan(electronVersion)}`; + ctx.parsedElectronVersion = electronVersion; + } else { + // semver.clean allows us to accept `v` versions and trims whitespace + const maybeVersion = semver.clean(electronVersion); + + if (maybeVersion) { + task.output = `Using Electron version: ${chalk.cyan(maybeVersion)}`; + ctx.parsedElectronVersion = maybeVersion; + } else { + throw new Error( + `Invalid Electron version: ${electronVersion}. Must be a valid semver version or one of 'latest', 'beta', or 'nightly'.`, + ); + } + } + }, + rendererOptions: { persistentOutput: true }, + }, { title: 'Initializing directory', task: async (_, task) => { @@ -145,7 +181,7 @@ export default async ({ if (templateModule.dependencies?.length) { task.output = `${pm.executable} ${pm.install} ${pm.dev} ${templateModule.dependencies.join(' ')}`; } - return await installDepList( + return await installDependencies( pm, dir, templateModule.dependencies || [], @@ -162,7 +198,7 @@ export default async ({ if (templateModule.devDependencies?.length) { task.output = `${pm.executable} ${pm.install} ${pm.dev} ${templateModule.devDependencies.join(' ')}`; } - await installDepList( + await installDependencies( pm, dir, templateModule.devDependencies || [], @@ -173,12 +209,12 @@ export default async ({ }, { title: 'Finalizing dependencies', - task: async (_, task) => { + task: async (ctx, task) => { return task.newListr([ { title: 'Installing common dependencies', task: async ({ pm }, task) => { - await initNPM(pm, dir, task); + await initNPM(pm, dir, ctx.parsedElectronVersion, task); }, exitOnError: false, }, diff --git a/packages/api/core/src/util/install-dependencies.ts b/packages/api/core/src/util/install-dependencies.ts index e966b7f575..81bb3312a9 100644 --- a/packages/api/core/src/util/install-dependencies.ts +++ b/packages/api/core/src/util/install-dependencies.ts @@ -14,13 +14,13 @@ export enum DepVersionRestriction { RANGE = 'RANGE', } -export default async ( +export async function installDependencies( pm: PMDetails, dir: string, deps: string[], depType = DepType.PROD, versionRestriction = DepVersionRestriction.RANGE, -): Promise => { +): Promise { d( 'installing', JSON.stringify(deps), @@ -51,4 +51,4 @@ export default async ( throw err; } } -}; +} diff --git a/packages/template/vite-typescript/spec/ViteTypeScriptTemplate.slow.spec.ts b/packages/template/vite-typescript/spec/ViteTypeScriptTemplate.slow.spec.ts index ef53390223..79a3e228b5 100644 --- a/packages/template/vite-typescript/spec/ViteTypeScriptTemplate.slow.spec.ts +++ b/packages/template/vite-typescript/spec/ViteTypeScriptTemplate.slow.spec.ts @@ -35,6 +35,7 @@ describe('ViteTypeScriptTemplate', () => { dir, template: path.resolve(__dirname, '..'), interactive: false, + electronVersion: '38.2.2', }); }); diff --git a/packages/template/webpack-typescript/spec/WebpackTypeScript.slow.spec.ts b/packages/template/webpack-typescript/spec/WebpackTypeScript.slow.spec.ts index 00ae308704..700e0ce03a 100644 --- a/packages/template/webpack-typescript/spec/WebpackTypeScript.slow.spec.ts +++ b/packages/template/webpack-typescript/spec/WebpackTypeScript.slow.spec.ts @@ -25,6 +25,7 @@ describe('WebpackTypeScriptTemplate', () => { dir, template: path.join(__dirname, '..'), interactive: false, + electronVersion: '38.2.2', }); });