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'); 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 */