Skip to content
Closed

- #9465

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions FULL_PNPM_SUPPORT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Full pnpm Support for electron-builder

## Problem

pnpm uses strict module resolution where packages can only require their direct dependencies. electron-builder had several `require()` and `require.resolve()` calls that assumed npm-style hoisting, causing failures like:

```
Cannot find module 'electron-webpack/dev-runner'
```

## Solution Overview

1. Created a helper function `resolveFromProject()` for resolving modules from the user's project context
2. Updated config loading to use the new helper
3. Deprecated the legacy `start` command (uses unmaintained electron-webpack)
4. Added pnpm workspace test fixtures

## Files Changed

### New Files
- `packages/app-builder-lib/src/util/projectModuleResolver.ts` - Module resolution helper

### Modified Files
- `packages/app-builder-lib/src/util/config/config.ts` - Fixed electron-webpack resolution
- `packages/app-builder-lib/src/util/config/load.ts` - Fixed parent config resolution
- `packages/app-builder-lib/src/util/resolve.ts` - Added projectDir param to resolveFunction
- `packages/app-builder-lib/src/electron/electronVersion.ts` - Fixed electron version detection for pnpm
- `packages/app-builder-lib/src/targets/archive.ts` - Fixed tar types (pre-existing issue)
- `packages/electron-builder/src/cli/start.ts` - Deprecated with helpful message
- `packages/electron-builder/src/cli/cli.ts` - Updated help text

### Test Files
- `test/fixtures/test-app-pnpm-workspace/` - New pnpm workspace fixture
- `test/src/packageManagerTest.ts` - Added pnpm workspace tests

## How the Fix Works

The core fix is the `resolveFromProject()` function which uses Node.js's `require.resolve()` with the `{ paths: [...] }` option. This tells Node where to look for modules:

```typescript
export function resolveFromProject(options: {
projectDir: string
moduleSpecifier: string
optional?: boolean
}): string | null {
// Build paths array from project directory up
const searchPaths: string[] = []
let currentDir = projectDir
while (currentDir !== root) {
if (fs.existsSync(path.join(currentDir, "node_modules"))) {
searchPaths.push(currentDir)
}
currentDir = path.dirname(currentDir)
}

return require.resolve(moduleSpecifier, { paths: searchPaths })
}
```

This works identically for npm/yarn (hoisted) and pnpm (strict) because we're explicitly telling Node where to look.

## Testing

To test manually with your pnpm project:

1. Link the local electron-builder to your project
2. Run `pnpm electron-builder build`

The existing tests can be run with:
```bash
pnpm test-linux # or
pnpm ci:test -- --grep "pnpm"
```

## Backward Compatibility

- npm/yarn: No change in behavior (same resolution mechanism)
- electron-webpack users: Deprecation warning but still works if installed
- All changes include fallbacks to original behavior

## Future Improvements

1. Consider adding a pnpm-specific configuration option for advanced use cases
2. Add more comprehensive error messages when modules can't be resolved
3. Consider removing electron-webpack support entirely in next major version
21 changes: 21 additions & 0 deletions packages/app-builder-lib/src/electron/electronVersion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import * as semver from "semver"
import { Configuration } from "../configuration"
import { getConfig } from "../util/config/config"
import { orNullIfFileNotExist } from "../util/config/load"
import { resolveFromProject } from "../util/projectModuleResolver"
import { getProjectRootPath } from "./search-module"

export type MetadataValue = Lazy<Record<string, any> | null>
Expand All @@ -26,6 +27,16 @@ export async function getElectronVersion(projectDir: string, config?: Configurat
export async function getElectronVersionFromInstalled(projectDir: string): Promise<string | null> {
for (const name of electronPackages) {
try {
// First try using resolveFromProject for pnpm compatibility
const packageJsonPath = resolveFromProject({
projectDir,
moduleSpecifier: `${name}/package.json`,
optional: true,
})
if (packageJsonPath) {
return (await readJson(packageJsonPath)).version
}
// Fallback to direct path check
return (await readJson(path.join(projectDir, "node_modules", name, "package.json"))).version
} catch (e: any) {
if (e.code !== "ENOENT") {
Expand All @@ -39,6 +50,16 @@ export async function getElectronVersionFromInstalled(projectDir: string): Promi
export async function getElectronPackage(projectDir: string) {
for (const name of electronPackages) {
try {
// First try using resolveFromProject for pnpm compatibility
const packageJsonPath = resolveFromProject({
projectDir,
moduleSpecifier: `${name}/package.json`,
optional: true,
})
if (packageJsonPath) {
return await readJson(packageJsonPath)
}
// Fallback to direct path check
return await readJson(path.join(projectDir, "node_modules", name, "package.json"))
} catch (e: any) {
if (e.code !== "ENOENT") {
Expand Down
4 changes: 2 additions & 2 deletions packages/app-builder-lib/src/targets/archive.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
import { debug7z, exec, exists, getPath7za, log, statOrNull, unlinkIfExists } from "builder-util"
import { move } from "fs-extra"
import * as path from "path"
import { create, CreateOptions, FileOptions } from "tar"
import { create, type TarOptionsWithAliasesAsyncFile } from "tar"
import { TmpDir } from "temp-file"
import { CompressionLevel } from "../core"
import { getLinuxToolsPath } from "./tools"

/** @internal */
export async function tar(compression: CompressionLevel | any, format: string, outFile: string, dirToArchive: string, isMacApp: boolean, tempDirManager: TmpDir): Promise<void> {
const tarFile = await tempDirManager.getTempFile({ suffix: ".tar" })
const tarArgs: CreateOptions & FileOptions = {
const tarArgs: TarOptionsWithAliasesAsyncFile = {
file: tarFile,
portable: true,
cwd: dirToArchive,
Expand Down
29 changes: 23 additions & 6 deletions packages/app-builder-lib/src/util/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Configuration } from "../../configuration"
import { FileSet } from "../../options/PlatformSpecificBuildOptions"
import { reactCra } from "../../presets/rectCra"
import { PACKAGE_VERSION } from "../../version"
import { resolveFromProject } from "../projectModuleResolver"
import { getConfig as _getConfig, loadParentConfig, orNullIfFileNotExist, ReadConfigRequest } from "./load"
const validateSchema = require("@develar/schema-utils")

Expand Down Expand Up @@ -57,13 +58,29 @@ export async function getConfig(
if ((dependencies != null && "react-scripts" in dependencies) || (devDependencies != null && "react-scripts" in devDependencies)) {
config.extends = "react-cra"
} else if (devDependencies != null && "electron-webpack" in devDependencies) {
let file = "electron-webpack/out/electron-builder.js"
try {
file = require.resolve(file)
} catch (_ignore) {
file = require.resolve("electron-webpack/electron-builder.yml")
// Note: electron-webpack is deprecated. This support is maintained for legacy projects.
let file = resolveFromProject({
projectDir,
moduleSpecifier: "electron-webpack/out/electron-builder.js",
optional: true,
})

if (file === null) {
file = resolveFromProject({
projectDir,
moduleSpecifier: "electron-webpack/electron-builder.yml",
optional: true,
})
}

if (file !== null) {
config.extends = `file:${file}`
} else {
log.warn(
{ package: "electron-webpack" },
"electron-webpack is listed in devDependencies but could not be resolved. " + "Note: electron-webpack is deprecated. Consider migrating to a modern build tool."
)
}
config.extends = `file:${file}`
}
}

Expand Down
20 changes: 15 additions & 5 deletions packages/app-builder-lib/src/util/config/load.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { createJiti } from "jiti"
import { load } from "js-yaml"
import { Lazy } from "lazy-val"
import * as path from "path"
import { resolveFromProject } from "../projectModuleResolver"
import { resolveModule } from "../resolve"

const jiti = createJiti(__filename)
Expand Down Expand Up @@ -107,11 +108,20 @@ export async function loadParentConfig<T>(request: ReadConfigRequest, spec: stri

let parentConfig = await orNullIfFileNotExist(readConfig<T>(path.resolve(request.projectDir, spec), request))
if (parentConfig == null && isFileSpec !== true) {
let resolved: string | null = null
try {
resolved = require.resolve(spec)
} catch (_e) {
// ignore
// First try to resolve from the project directory (for pnpm compatibility)
let resolved = resolveFromProject({
projectDir: request.projectDir,
moduleSpecifier: spec,
optional: true,
})

// Fallback: try standard resolution (for electron-builder's own dependencies)
if (resolved === null) {
try {
resolved = require.resolve(spec)
} catch (_e) {
// ignore
}
}

if (resolved != null) {
Expand Down
80 changes: 80 additions & 0 deletions packages/app-builder-lib/src/util/projectModuleResolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { log } from "builder-util"
import * as path from "path"
import * as fs from "fs"

export interface ResolveFromProjectOptions {
/** The project directory to resolve from */
projectDir: string
/** Module specifier (e.g., "electron-webpack/out/electron-builder.js") */
moduleSpecifier: string
/** If true, returns null instead of throwing when module not found */
optional?: boolean
}

/**
* Resolves a module from the user's project directory context.
* This is necessary for pnpm compatibility where modules can only
* require their direct dependencies.
*
* @param options Resolution options
* @returns The resolved module path, or null if optional and not found
* @throws Error if module not found and not optional
*/
export function resolveFromProject(options: ResolveFromProjectOptions): string | null {
const { projectDir, moduleSpecifier, optional = false } = options

// Build the paths array for resolution
// Start from project's node_modules and walk up
const searchPaths: string[] = []
let currentDir = projectDir
const root = path.parse(currentDir).root

while (currentDir !== root) {
const nodeModulesPath = path.join(currentDir, "node_modules")
if (fs.existsSync(nodeModulesPath)) {
searchPaths.push(currentDir)
}
const parentDir = path.dirname(currentDir)
if (parentDir === currentDir) break
currentDir = parentDir
}

// If no paths found, at least try the project directory
if (searchPaths.length === 0) {
searchPaths.push(projectDir)
}

try {
const resolved = require.resolve(moduleSpecifier, { paths: searchPaths })
log.debug({ moduleSpecifier, resolved }, "resolved module from project context")
return resolved
} catch (error: any) {
if (optional) {
log.debug({ moduleSpecifier, projectDir, error: error.message }, "optional module not found in project")
return null
}
throw new Error(
`Cannot resolve module "${moduleSpecifier}" from project directory "${projectDir}". ` +
`This module must be installed in your project. ` +
`If you're using pnpm, ensure the module is listed in your dependencies. ` +
`Original error: ${error.message}`
)
}
}

/**
* Checks if a module exists in the user's project context.
*
* @param projectDir The project directory
* @param moduleSpecifier The module to check for
* @returns true if the module can be resolved
*/
export function moduleExistsInProject(projectDir: string, moduleSpecifier: string): boolean {
return (
resolveFromProject({
projectDir,
moduleSpecifier,
optional: true,
}) !== null
)
}
27 changes: 23 additions & 4 deletions packages/app-builder-lib/src/util/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { log } from "builder-util/out/log"
import debug from "debug"
import * as path from "path"
import * as requireMaybe from "../../helpers/dynamic-import"
import { resolveFromProject } from "./projectModuleResolver"

export async function resolveModule<T>(type: string | undefined, name: string): Promise<T> {
try {
Expand All @@ -12,22 +13,37 @@ export async function resolveModule<T>(type: string | undefined, name: string):
}
}

export async function resolveFunction<T>(type: string | undefined, executor: T | string, name: string): Promise<T> {
export async function resolveFunction<T>(type: string | undefined, executor: T | string, name: string, projectDir?: string): Promise<T> {
if (executor == null || typeof executor !== "string") {
// is already function or explicitly ignored by user
return executor
}

let p = executor as string
if (p.startsWith(".")) {
p = path.resolve(p)
p = path.resolve(projectDir || process.cwd(), p)
}

try {
p = require.resolve(p)
// First try project context resolution (for pnpm compatibility)
if (projectDir && !path.isAbsolute(p)) {
const resolved = resolveFromProject({
projectDir,
moduleSpecifier: p,
optional: true,
})
if (resolved !== null) {
p = resolved
}
}

// Fallback to standard resolution
if (!path.isAbsolute(p)) {
p = require.resolve(p)
}
} catch (e: any) {
debug(e)
p = path.resolve(p)
p = path.resolve(projectDir || process.cwd(), p)
}

const m: any = await resolveModule(type, p)
Expand All @@ -38,3 +54,6 @@ export async function resolveFunction<T>(type: string | undefined, executor: T |
return namedExport
}
}

// Re-export for convenience
export { resolveFromProject, moduleExistsInProject } from "./projectModuleResolver"
2 changes: 1 addition & 1 deletion packages/electron-builder/src/cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ void createYargs()
)
.command(
"start",
"Run application in a development mode using electron-webpack",
"[DEPRECATED] Run application in dev mode (requires electron-webpack)",
yargs => yargs,
wrap(() => start())
)
Expand Down
Loading
Loading