From 68219691e7839d90e61ca3b09f88365258f317a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 19:20:37 +0000 Subject: [PATCH 1/2] fix: throw a descriptive error when the out dir would exclude the app from packaging Out directories are automatically excluded from the packaged app. When the out dir is the app dir itself, or is a subdirectory of the app dir that contains the app's main entry point, packaging failed with a misleading "Application manifest was not found" or "The main entry point to your app was not found" error that blamed the ignore option. validateElectronApp now receives the resolved ignored out dirs and throws a targeted error explaining that the out dir is excluded from packaging and must live outside the app source. Fixes #1685 --- src/common.ts | 24 ++++++++++- src/platform.ts | 8 +++- test/common.spec.ts | 20 +++++++++ .../package.json | 3 ++ test/packager.spec.ts | 43 +++++++++++++++++++ 5 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 test/fixtures/validate-failure-main-in-out-dir/package.json diff --git a/src/common.ts b/src/common.ts index 3a052532..0d48e31d 100644 --- a/src/common.ts +++ b/src/common.ts @@ -99,6 +99,12 @@ export function normalizePath(pathToNormalize: string) { return pathToNormalize.replace(/\\/g, '/'); } +function findOutDirContaining(fullPath: string, ignoredOutDirs: string[]): string | undefined { + return ignoredOutDirs.find( + (outDir) => fullPath === outDir || fullPath.startsWith(`${outDir}${path.sep}`), + ); +} + /** * Validates that the application directory contains a package.json file, and that there exists an * appropriate main entry point file, per the rules of the "main" field in package.json. @@ -107,13 +113,23 @@ export function normalizePath(pathToNormalize: string) { * * @param appDir - the directory specified by the user * @param bundledAppDir - the directory where the appDir is copied to in the bundled Electron app + * @param ignoredOutDirs - resolved out directories that are excluded from the bundled app */ -export async function validateElectronApp(appDir: string, bundledAppDir: string) { +export async function validateElectronApp( + appDir: string, + bundledAppDir: string, + ignoredOutDirs: string[] = [], +) { debug('Validating bundled Electron app'); debug('Checking for a package.json file'); const bundledPackageJSONPath = path.join(bundledAppDir, 'package.json'); if (!fs.existsSync(bundledPackageJSONPath)) { + if (ignoredOutDirs.includes(path.resolve(appDir))) { + throw new Error( + `The out directory (${path.resolve(appDir)}) is the same as your app directory. The out directory is automatically excluded from packaging, so nothing would be packaged; choose an out directory outside of your app directory`, + ); + } const originalPackageJSONPath = path.join(appDir, 'package.json'); throw new Error( `Application manifest was not found. Make sure "${originalPackageJSONPath}" exists and does not get ignored by your ignore option`, @@ -126,6 +142,12 @@ export async function validateElectronApp(appDir: string, bundledAppDir: string) const mainScript = path.resolve(bundledAppDir, mainScriptBasename); if (!fs.existsSync(mainScript)) { const originalMainScript = path.join(appDir, mainScriptBasename); + const outDir = findOutDirContaining(path.resolve(appDir, mainScriptBasename), ignoredOutDirs); + if (outDir) { + throw new Error( + `The out directory (${outDir}) is inside your app directory and contains your app's main entry point (${originalMainScript}). The out directory is automatically excluded from packaging; choose an out directory outside of your app directory`, + ); + } throw new Error( `The main entry point to your app was not found. Make sure "${originalMainScript}" exists and does not get ignored by your ignore option`, ); diff --git a/src/platform.ts b/src/platform.ts index 1c4a91f0..5284c686 100644 --- a/src/platform.ts +++ b/src/platform.ts @@ -15,7 +15,7 @@ import { validateElectronApp, warning, } from './common.js'; -import { userPathFilter } from './copy-filter.js'; +import { generateIgnoredOutDirs, userPathFilter } from './copy-filter.js'; import { runHooks } from './hooks.js'; import crypto from 'node:crypto'; import type { ProcessedOptionsWithSinglePlatformArch } from './types.js'; @@ -176,7 +176,11 @@ export class App { async buildApp() { await this.copyTemplate(); - await validateElectronApp(this.opts.dir, this.originalResourcesAppDir); + await validateElectronApp( + this.opts.dir, + this.originalResourcesAppDir, + generateIgnoredOutDirs(this.opts), + ); await this.asarApp(); } diff --git a/test/common.spec.ts b/test/common.spec.ts index a0525439..ff24b434 100644 --- a/test/common.spec.ts +++ b/test/common.spec.ts @@ -104,6 +104,26 @@ describe('validateElectronApp', () => { `The main entry point to your app was not found. Make sure "${path.join('original-dir', 'main.js')}" exists and does not get ignored by your ignore option`, ); }); + + it('fails on an Electron app whose app directory is the out directory', async () => { + const fixture = path.join(__dirname, 'fixtures', 'validate-failure-without-package-json'); + + await expect( + validateElectronApp('original-dir', fixture, [path.resolve('original-dir')]), + ).rejects.toThrow( + `The out directory (${path.resolve('original-dir')}) is the same as your app directory. The out directory is automatically excluded from packaging, so nothing would be packaged; choose an out directory outside of your app directory`, + ); + }); + + it('fails on an Electron app whose main entry point is inside the out directory', async () => { + const fixture = path.join(__dirname, 'fixtures', 'validate-failure-main-in-out-dir'); + + await expect( + validateElectronApp('original-dir', fixture, [path.resolve('original-dir', 'dist')]), + ).rejects.toThrow( + `The out directory (${path.resolve('original-dir', 'dist')}) is inside your app directory and contains your app's main entry point (${path.join('original-dir', 'dist', 'main.js')}). The out directory is automatically excluded from packaging; choose an out directory outside of your app directory`, + ); + }); }); describe('createAsarOpts', () => { diff --git a/test/fixtures/validate-failure-main-in-out-dir/package.json b/test/fixtures/validate-failure-main-in-out-dir/package.json new file mode 100644 index 00000000..5c29fa61 --- /dev/null +++ b/test/fixtures/validate-failure-main-in-out-dir/package.json @@ -0,0 +1,3 @@ +{ + "main": "dist/main.js" +} diff --git a/test/packager.spec.ts b/test/packager.spec.ts index 63c57de0..b4d8ac22 100644 --- a/test/packager.spec.ts +++ b/test/packager.spec.ts @@ -505,6 +505,49 @@ describe('packager', () => { expect(fs.existsSync(path.join(resourcesPath, 'app', path.basename(opts.out)))).toBe(false); }); + it('should fail with a descriptive error if the out dir is the app dir', async ({ baseOpts }) => { + const opts = { + ...baseOpts, + out: baseOpts.dir, + }; + + await expect(packager(opts)).rejects.toThrowError( + `The out directory (${path.resolve(opts.dir)}) is the same as your app directory. The out directory is automatically excluded from packaging, so nothing would be packaged; choose an out directory outside of your app directory`, + ); + }); + + it('should fail with a descriptive error if the out dir contains the main entry point', async ({ + baseOpts, + }) => { + const fixture = path.join(__dirname, 'fixtures', 'basic'); + const appDir = path.join(baseOpts.out, 'app'); + await fs.promises.cp(fixture, appDir, { + dereference: true, + filter: (file) => path.basename(file) !== 'node_modules', + recursive: true, + }); + const packageJSON = JSON.parse( + await fs.promises.readFile(path.join(appDir, 'package.json'), 'utf8'), + ); + packageJSON.main = 'dist/main.js'; + await fs.promises.writeFile( + path.join(appDir, 'package.json'), + JSON.stringify(packageJSON, null, 2), + ); + await fs.promises.mkdir(path.join(appDir, 'dist'), { recursive: true }); + await fs.promises.rename(path.join(appDir, 'main.js'), path.join(appDir, 'dist', 'main.js')); + + const opts = { + ...baseOpts, + dir: appDir, + out: path.join(appDir, 'dist'), + }; + + await expect(packager(opts)).rejects.toThrowError( + `The out directory (${path.join(appDir, 'dist')}) is inside your app directory and contains your app's main entry point (${path.join(appDir, 'dist', 'main.js')}). The out directory is automatically excluded from packaging; choose an out directory outside of your app directory`, + ); + }); + describe('hooks', () => { it.for([ { From 58f567016ce5c16594ac1c73a1609766692459de Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 19:30:04 +0000 Subject: [PATCH 2/2] fix: canonicalize paths when detecting an out dir that excludes the app Use realpath-canonicalized paths and a path.relative-based containment check so the out-dir detection works across symlinked paths (e.g. /var vs /private/var in macOS tmpdirs) and case-insensitive filesystems. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01RX3wGLrrpx1fBfqXCZt1Tx --- src/common.ts | 35 +++++++++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/src/common.ts b/src/common.ts index 0d48e31d..063f96f3 100644 --- a/src/common.ts +++ b/src/common.ts @@ -99,10 +99,32 @@ export function normalizePath(pathToNormalize: string) { return pathToNormalize.replace(/\\/g, '/'); } +/** + * Resolves a path and canonicalizes it via `realpath`, so that two paths referring to the same + * location compare as equal even when one of them goes through a symlink (e.g. `/var` vs + * `/private/var` on macOS). Falls back to the resolved path if it does not exist. + */ +function canonicalizePath(pathToCanonicalize: string): string { + const resolvedPath = path.resolve(pathToCanonicalize); + try { + return fs.realpathSync(resolvedPath); + } catch { + return resolvedPath; + } +} + +/** + * Whether `childPath` is equal to or contained within `parentPath`. Both paths must already be + * canonicalized. Uses `path.relative` so that, on Windows, the comparison is case-insensitive. + */ +function isPathInside(childPath: string, parentPath: string): boolean { + const relativePath = path.relative(parentPath, childPath); + return relativePath === '' || (!relativePath.startsWith('..') && !path.isAbsolute(relativePath)); +} + function findOutDirContaining(fullPath: string, ignoredOutDirs: string[]): string | undefined { - return ignoredOutDirs.find( - (outDir) => fullPath === outDir || fullPath.startsWith(`${outDir}${path.sep}`), - ); + const canonicalFullPath = canonicalizePath(fullPath); + return ignoredOutDirs.find((outDir) => isPathInside(canonicalFullPath, canonicalizePath(outDir))); } /** @@ -125,7 +147,12 @@ export async function validateElectronApp( const bundledPackageJSONPath = path.join(bundledAppDir, 'package.json'); if (!fs.existsSync(bundledPackageJSONPath)) { - if (ignoredOutDirs.includes(path.resolve(appDir))) { + const canonicalAppDir = canonicalizePath(appDir); + if ( + ignoredOutDirs.some( + (outDir) => path.relative(canonicalizePath(outDir), canonicalAppDir) === '', + ) + ) { throw new Error( `The out directory (${path.resolve(appDir)}) is the same as your app directory. The out directory is automatically excluded from packaging, so nothing would be packaged; choose an out directory outside of your app directory`, );