From 98e1833a2486d4fe64fd3686893db214a01e3502 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 19:13:11 +0000 Subject: [PATCH 1/2] feat(core): add sanitizePackageJson hook Packaged apps currently ship the project's full package.json with only config.forge removed. Forge now sanitizes the copied package.json by default, stripping dev-only fields (devDependencies, scripts, workspace and package manager settings, tooling config) while keeping runtime fields. Providing a sanitizePackageJson hook from the Forge config or a plugin replaces the default entirely; the default is exported as defaultSanitizePackageJson so custom hooks can extend it. The sanitize step runs in the packager afterCopy phase, after all packageAfterCopy hooks and before prune. Stripping devDependencies before prune is safe: the prune walker only uses root devDependencies to mark dev-only modules for removal, and unwalked modules are pruned anyway. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HEPrpBevFu6CmqKzeNHmoA --- packages/api/core/src/api/index.ts | 2 + packages/api/core/src/api/package.ts | 18 +--- .../api/core/src/util/plugin-interface.ts | 11 +++ .../core/src/util/sanitize-package-json.ts | 84 +++++++++++++++++++ packages/utils/types/src/index.ts | 19 +++++ 5 files changed, 118 insertions(+), 16 deletions(-) create mode 100644 packages/api/core/src/util/sanitize-package-json.ts diff --git a/packages/api/core/src/api/index.ts b/packages/api/core/src/api/index.ts index da3cf53025..f711e3c9e0 100644 --- a/packages/api/core/src/api/index.ts +++ b/packages/api/core/src/api/index.ts @@ -5,6 +5,7 @@ import { } from '@electron-forge/shared-types'; import ForgeUtils from '../util/index.js'; +import { defaultSanitizePackageJson } from '../util/sanitize-package-json.js'; import make, { MakeOptions } from './make.js'; import _package, { PackageOptions } from './package.js'; @@ -72,5 +73,6 @@ export { ReleaseOptions, StartOptions, api, + defaultSanitizePackageJson, utils, }; diff --git a/packages/api/core/src/api/package.ts b/packages/api/core/src/api/package.ts index 0c057dd339..3334261c30 100644 --- a/packages/api/core/src/api/package.ts +++ b/packages/api/core/src/api/package.ts @@ -14,7 +14,6 @@ import { import { getElectronVersion, listrCompatibleRebuildHook, - writeJson, } from '@electron-forge/core-utils'; import { ForgeArch, @@ -36,6 +35,7 @@ import { warn } from '../util/messages.js'; import getCurrentOutDir from '../util/out-dir.js'; import { readMutatedPackageJson } from '../util/read-package-json.js'; import resolveDir from '../util/resolve-dir.js'; +import { sanitizeCopiedPackageJson } from '../util/sanitize-package-json.js'; const d = debug('electron-forge:packager'); @@ -309,21 +309,7 @@ export const listrPackage = ( signalRebuildDone.get(targetKey)?.pop()?.(); }, async ({ buildPath }) => { - const copiedPackageJSON = await readMutatedPackageJson( - buildPath, - forgeConfig, - ); - if ( - copiedPackageJSON.config && - copiedPackageJSON.config.forge - ) { - delete copiedPackageJSON.config.forge; - } - await writeJson( - path.resolve(buildPath, 'package.json'), - copiedPackageJSON, - { spaces: 2 }, - ); + await sanitizeCopiedPackageJson(forgeConfig, buildPath); }, ...(await resolveHooks( forgeConfig.packagerConfig.afterCopy, diff --git a/packages/api/core/src/util/plugin-interface.ts b/packages/api/core/src/util/plugin-interface.ts index 129954a342..e64d2ec963 100644 --- a/packages/api/core/src/util/plugin-interface.ts +++ b/packages/api/core/src/util/plugin-interface.ts @@ -2,6 +2,7 @@ import { styleText } from 'node:util'; import { PluginBase } from '@electron-forge/plugin-base'; import { + ForgeHookName, ForgeListrTaskDefinition, ForgeMutatingHookFn, ForgeMutatingHookSignatures, @@ -178,6 +179,16 @@ export default class PluginInterface implements IForgePluginInterface { return result; } + hasHook(hookName: ForgeHookName): boolean { + return this.plugins.some((plugin) => { + if (typeof plugin.getHooks !== 'function') return false; + const hooks = plugin.getHooks()[hookName]; + return Array.isArray(hooks) + ? hooks.length > 0 + : typeof hooks === 'function'; + }); + } + async overrideStartLogic(opts: StartOptions): Promise { let newStartFn; const claimed: string[] = []; diff --git a/packages/api/core/src/util/sanitize-package-json.ts b/packages/api/core/src/util/sanitize-package-json.ts new file mode 100644 index 0000000000..db955c71c2 --- /dev/null +++ b/packages/api/core/src/util/sanitize-package-json.ts @@ -0,0 +1,84 @@ +import path from 'node:path'; + +import { writeJson } from '@electron-forge/core-utils'; +import { ResolvedForgeConfig } from '@electron-forge/shared-types'; + +import { runMutatingHook } from './hook.js'; +import { readMutatedPackageJson } from './read-package-json.js'; + +const DEFAULT_STRIPPED_FIELDS = [ + 'devDependencies', + 'scripts', + 'workspaces', + 'packageManager', + 'resolutions', + 'overrides', + 'pnpm', + 'private', + 'publishConfig', + 'devEngines', + 'jest', + 'eslintConfig', + 'prettier', + 'browserslist', + 'lint-staged', + 'nano-staged', + 'husky', + 'commitlint', + 'mocha', + 'ava', + 'nyc', + 'c8', + 'tap', + 'xo', + 'standard', +]; + +/** + * The default implementation of the `sanitizePackageJson` hook. Strips + * development-only fields (`devDependencies`, `scripts`, workspace and + * package manager settings, and common tooling configuration) from the + * packaged app's package.json, removes `config.forge`, and drops `config` + * entirely if it is empty afterwards. Runtime-relevant fields such as `main`, + * `dependencies`, and `optionalDependencies` are kept. + * + * Custom `sanitizePackageJson` hooks can call this function to extend the + * default behavior rather than replace it. + */ +export function defaultSanitizePackageJson( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + packageJson: Record, + // eslint-disable-next-line @typescript-eslint/no-explicit-any +): Record { + for (const field of DEFAULT_STRIPPED_FIELDS) { + delete packageJson[field]; + } + if (packageJson.config) { + delete packageJson.config.forge; + if (Object.keys(packageJson.config).length === 0) { + delete packageJson.config; + } + } + return packageJson; +} + +/** + * Rewrites the package.json that was copied into `buildPath`, running any + * `sanitizePackageJson` hooks provided by the Forge config or plugins, or + * {@link defaultSanitizePackageJson} when none are provided. + */ +export async function sanitizeCopiedPackageJson( + forgeConfig: ResolvedForgeConfig, + buildPath: string, +): Promise { + const packageJson = await readMutatedPackageJson(buildPath, forgeConfig); + const hasUserHook = + typeof forgeConfig.hooks?.sanitizePackageJson === 'function' || + forgeConfig.pluginInterface.hasHook('sanitizePackageJson'); + const sanitized = hasUserHook + ? await runMutatingHook(forgeConfig, 'sanitizePackageJson', packageJson) + : defaultSanitizePackageJson(packageJson); + await writeJson(path.resolve(buildPath, 'package.json'), sanitized, { + spaces: 2, + }); +} diff --git a/packages/utils/types/src/index.ts b/packages/utils/types/src/index.ts index 63836a1435..a57481985e 100644 --- a/packages/utils/types/src/index.ts +++ b/packages/utils/types/src/index.ts @@ -79,6 +79,21 @@ export interface ForgeMutatingHookSignatures { resolveForgeConfig: [currentConfig: ResolvedForgeConfig]; // eslint-disable-next-line @typescript-eslint/no-explicit-any readPackageJson: [packageJson: Record]; + /** + * Mutates the packaged app's package.json before it is written into the + * build directory. Runs in the packager's afterCopy phase, after all + * `packageAfterCopy` hooks and before pruning. + * + * When neither the Forge config nor any plugin provides this hook, Forge + * applies its default sanitizer (`defaultSanitizePackageJson` from + * `@electron-forge/core`), which strips development-only fields such as + * `devDependencies`, `scripts`, and tooling configuration, and removes + * `config.forge`. Providing this hook replaces the default entirely; call + * `defaultSanitizePackageJson` from your hook to extend the default + * behavior instead of replacing it. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sanitizePackageJson: [packageJson: Record]; } export type ForgeHookName = keyof (ForgeSimpleHookSignatures & @@ -120,6 +135,10 @@ export interface IForgePluginInterface { hookName: Hook, item: ForgeMutatingHookSignatures[Hook][0], ): Promise; + /** + * Whether any plugin registers at least one handler for the given hook. + */ + hasHook(hookName: ForgeHookName): boolean; overrideStartLogic(opts: StartOptions): Promise; } /* eslint-enable @typescript-eslint/no-explicit-any */ From e9eecaeca699745c44209df4fc2f57f01fe754ce Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Jul 2026 19:13:14 +0000 Subject: [PATCH 2/2] test(core): cover sanitizePackageJson hook behavior Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HEPrpBevFu6CmqKzeNHmoA --- .../fast/util/sanitize-package-json.spec.ts | 201 ++++++++++++++++++ .../api/core/spec/slow/package.slow.spec.ts | 4 + 2 files changed, 205 insertions(+) create mode 100644 packages/api/core/spec/fast/util/sanitize-package-json.spec.ts diff --git a/packages/api/core/spec/fast/util/sanitize-package-json.spec.ts b/packages/api/core/spec/fast/util/sanitize-package-json.spec.ts new file mode 100644 index 0000000000..1cf8d28dbb --- /dev/null +++ b/packages/api/core/spec/fast/util/sanitize-package-json.spec.ts @@ -0,0 +1,201 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { readJson } from '@electron-forge/core-utils'; +import { ResolvedForgeConfig } from '@electron-forge/shared-types'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + defaultSanitizePackageJson, + sanitizeCopiedPackageJson, +} from '../../../src/util/sanitize-package-json.js'; + +const fakeConfig = { + pluginInterface: { + triggerMutatingHook: vi.fn(), + hasHook: vi.fn(), + }, +} as unknown as ResolvedForgeConfig; + +beforeEach(() => { + vi.mocked(fakeConfig.pluginInterface.triggerMutatingHook).mockImplementation( + (_, arg1) => Promise.resolve(arg1), + ); + vi.mocked(fakeConfig.pluginInterface.hasHook).mockReturnValue(false); +}); + +describe('defaultSanitizePackageJson', () => { + it('strips dev-only fields and keeps runtime fields', () => { + const sanitized = defaultSanitizePackageJson({ + name: 'my-app', + productName: 'My App', + version: '1.0.0', + main: 'index.js', + type: 'module', + dependencies: { debug: '^4.0.0' }, + optionalDependencies: { fsevents: '^2.0.0' }, + peerDependencies: { electron: '*' }, + devDependencies: { vitest: '^4.0.0' }, + scripts: { start: 'electron-forge start' }, + workspaces: ['packages/*'], + packageManager: 'yarn@4.0.0', + resolutions: { debug: '4.1.0' }, + overrides: { debug: '4.1.0' }, + pnpm: { patchedDependencies: {} }, + private: true, + publishConfig: { access: 'public' }, + devEngines: { node: '>=20' }, + jest: {}, + eslintConfig: {}, + prettier: {}, + browserslist: ['last 2 versions'], + 'lint-staged': {}, + 'nano-staged': {}, + husky: {}, + commitlint: {}, + mocha: {}, + ava: {}, + nyc: {}, + c8: {}, + tap: {}, + xo: {}, + standard: {}, + }); + + expect(sanitized).toEqual({ + name: 'my-app', + productName: 'My App', + version: '1.0.0', + main: 'index.js', + type: 'module', + dependencies: { debug: '^4.0.0' }, + optionalDependencies: { fsevents: '^2.0.0' }, + peerDependencies: { electron: '*' }, + }); + }); + + it('removes config.forge and drops config when empty', () => { + const sanitized = defaultSanitizePackageJson({ + name: 'my-app', + config: { forge: './forge.config.js' }, + }); + + expect(sanitized).not.toHaveProperty('config'); + }); + + it('keeps other config values when removing config.forge', () => { + const sanitized = defaultSanitizePackageJson({ + name: 'my-app', + config: { forge: './forge.config.js', other: true }, + }); + + expect(sanitized.config).toEqual({ other: true }); + }); +}); + +describe('sanitizeCopiedPackageJson', () => { + let buildPath: string; + + beforeEach(async () => { + buildPath = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'forge-sanitize-'), + ); + + return async () => { + await fs.promises.rm(buildPath, { recursive: true, force: true }); + }; + }); + + const writePackageJson = async (packageJson: Record) => { + await fs.promises.writeFile( + path.join(buildPath, 'package.json'), + JSON.stringify(packageJson), + ); + }; + + it('applies the default sanitizer when no hook is provided', async () => { + await writePackageJson({ + name: 'my-app', + main: 'index.js', + dependencies: { debug: '^4.0.0' }, + devDependencies: { vitest: '^4.0.0' }, + scripts: { start: 'electron-forge start' }, + config: { forge: { packagerConfig: {} } }, + }); + + await sanitizeCopiedPackageJson({ ...fakeConfig }, buildPath); + + expect(await readJson(path.join(buildPath, 'package.json'))).toEqual({ + name: 'my-app', + main: 'index.js', + dependencies: { debug: '^4.0.0' }, + }); + }); + + it('replaces the default sanitizer with a hook from the forge config', async () => { + await writePackageJson({ + name: 'my-app', + devDependencies: { vitest: '^4.0.0' }, + scripts: { start: 'electron-forge start' }, + }); + + const hook = vi.fn().mockImplementation(async (_config, packageJson) => { + delete packageJson.scripts; + return packageJson; + }); + + await sanitizeCopiedPackageJson( + { ...fakeConfig, hooks: { sanitizePackageJson: hook } }, + buildPath, + ); + + expect(hook).toHaveBeenCalledOnce(); + // fields the default would strip survive when the user hook keeps them + expect(await readJson(path.join(buildPath, 'package.json'))).toEqual({ + name: 'my-app', + devDependencies: { vitest: '^4.0.0' }, + }); + }); + + it('replaces the default sanitizer when a plugin provides the hook', async () => { + await writePackageJson({ + name: 'my-app', + devDependencies: { vitest: '^4.0.0' }, + }); + + vi.mocked(fakeConfig.pluginInterface.hasHook).mockImplementation( + (hookName) => hookName === 'sanitizePackageJson', + ); + vi.mocked( + fakeConfig.pluginInterface.triggerMutatingHook, + ).mockImplementation(async (hookName, packageJson) => + hookName === 'sanitizePackageJson' + ? { ...packageJson, sanitizedByPlugin: true } + : packageJson, + ); + + await sanitizeCopiedPackageJson({ ...fakeConfig }, buildPath); + + expect(await readJson(path.join(buildPath, 'package.json'))).toEqual({ + name: 'my-app', + devDependencies: { vitest: '^4.0.0' }, + sanitizedByPlugin: true, + }); + }); + + it('writes the value returned by the hook to disk', async () => { + await writePackageJson({ name: 'my-app', main: 'index.js' }); + + const hook = vi.fn().mockResolvedValue({ replaced: true }); + + await sanitizeCopiedPackageJson( + { ...fakeConfig, hooks: { sanitizePackageJson: hook } }, + buildPath, + ); + + expect(await readJson(path.join(buildPath, 'package.json'))).toEqual({ + replaced: true, + }); + }); +}); diff --git a/packages/api/core/spec/slow/package.slow.spec.ts b/packages/api/core/spec/slow/package.slow.spec.ts index ead9a54154..46c405c016 100644 --- a/packages/api/core/spec/slow/package.slow.spec.ts +++ b/packages/api/core/spec/slow/package.slow.spec.ts @@ -51,6 +51,10 @@ describe('Package', () => { }); expect(cleanPackageJSON).not.toHaveProperty('config.forge'); + // the default sanitizePackageJson behavior strips dev-only fields + expect(cleanPackageJSON).not.toHaveProperty('devDependencies'); + expect(cleanPackageJSON).not.toHaveProperty('scripts'); + // should leave the original Forge config intact const normalPackageJSON = await readRawPackageJson(dir); expect(normalPackageJSON).toHaveProperty('config.forge');