Skip to content
Closed
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
201 changes: 201 additions & 0 deletions packages/api/core/spec/fast/util/sanitize-package-json.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => {
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,
});
});
});
4 changes: 4 additions & 0 deletions packages/api/core/spec/slow/package.slow.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
2 changes: 2 additions & 0 deletions packages/api/core/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -72,5 +73,6 @@ export {
ReleaseOptions,
StartOptions,
api,
defaultSanitizePackageJson,
utils,
};
18 changes: 2 additions & 16 deletions packages/api/core/src/api/package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import {
import {
getElectronVersion,
listrCompatibleRebuildHook,
writeJson,
} from '@electron-forge/core-utils';
import {
ForgeArch,
Expand All @@ -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');

Expand Down Expand Up @@ -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,
Expand Down
11 changes: 11 additions & 0 deletions packages/api/core/src/util/plugin-interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { styleText } from 'node:util';

import { PluginBase } from '@electron-forge/plugin-base';
import {
ForgeHookName,
ForgeListrTaskDefinition,
ForgeMutatingHookFn,
ForgeMutatingHookSignatures,
Expand Down Expand Up @@ -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<StartResult> {
let newStartFn;
const claimed: string[] = [];
Expand Down
84 changes: 84 additions & 0 deletions packages/api/core/src/util/sanitize-package-json.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
): Record<string, any> {
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<void> {
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,
});
}
Loading
Loading