diff --git a/README.md b/README.md index e24afe6..9b7dd7c 100644 --- a/README.md +++ b/README.md @@ -159,10 +159,12 @@ During analysis and sync, files are displayed with status indicators: packageJsonSync: ['dependencies', 'devDependencies', 'scripts'] ``` -**Supported keys:** `dependencies`, `devDependencies`, `peerDependencies`, `optionalDependencies`, `scripts`, `engines`, `packageManager`, `overrides` +**Supported keys:** `dependencies`, `devDependencies`, `peerDependencies`, `optionalDependencies`, `scripts`, `engines`, `packageManager`, `overrides`, `exports`, `pnpm` It **adds** new keys and **updates** existing ones to match upstream, but never **removes** keys -that only exist in your fork — so extra dependencies and scripts survive each sync. +that only exist in your fork — so extra dependencies and scripts survive each sync. `scripts` and +`exports` are add-only: an entry your fork already defines is never rewritten. `exports` only +merges when both sides are subpath maps (`{ ".": …, "./config": … }`). ## Contributions (pull from forks) diff --git a/src/config/types.ts b/src/config/types.ts index c085ec6..39d63d1 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -20,6 +20,7 @@ export type PackageJsonSyncKey = | 'engines' | 'packageManager' | 'overrides' + | 'exports' | 'pnpm'; /** @@ -170,6 +171,7 @@ export const cellaConfigSchema = z 'engines', 'packageManager', 'overrides', + 'exports', 'pnpm', ]), ) diff --git a/src/services/packages.ts b/src/services/packages.ts index e43a8e7..5f5e343 100644 --- a/src/services/packages.ts +++ b/src/services/packages.ts @@ -133,6 +133,31 @@ function safeMergeRecord( return { merged: sorted, changed, added }; } +/** A subpath map (`{ ".": …, "./config": … }`), as opposed to a string or a conditions object. */ +function isSubpathMap(value: unknown): value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false; + return Object.keys(value).every((name) => name.startsWith('.')); +} + +/** + * Safe merge for the `exports` key — add upstream subpaths the fork lacks, never touch the + * fork's own. Only merges when both sides are subpath maps: a string or conditions object + * can't take extra subpaths, and condition order is significant. Keeps the fork's key order. + */ +function safeMergeExports( + forkExports: unknown, + upstreamExports: unknown, +): { merged: Record; changed: boolean; added: string[] } | undefined { + if (!isSubpathMap(upstreamExports)) return undefined; + if (forkExports !== undefined && !isSubpathMap(forkExports)) return undefined; + + const merged = { ...(forkExports || {}) }; + const added = Object.keys(upstreamExports).filter((name) => !(name in merged)); + for (const name of added) merged[name] = upstreamExports[name]; + + return { merged, changed: added.length > 0, added }; +} + /** * Safe merge for the `pnpm` key — recurse into sub-objects with add/bump-only logic. */ @@ -318,6 +343,18 @@ async function syncPackageJson( continue; } + if (key === 'exports') { + // Add-only, subpath maps only: the fork gains new upstream subpaths, keeps any it defines + // itself, and a subpath it has repointed stays repointed. + const result = safeMergeExports(forkPkg.exports, upstreamPkg.exports); + if (result?.changed) { + forkPkg.exports = result.merged; + updated = true; + changes.push(...result.added.map((name) => `exports.${name}: added`)); + } + continue; + } + // All Record keys — safe merge. // Scripts are add-only (never overwrite fork scripts); the rest also bump versions. const forkValue = forkPkg[key] as Record | undefined; diff --git a/tests/packages.test.ts b/tests/packages.test.ts index 7e241d3..fc3f33a 100644 --- a/tests/packages.test.ts +++ b/tests/packages.test.ts @@ -223,6 +223,47 @@ describe('packages merge', () => { expect(scripts.test).toBe('vitest'); }); + it('should add new export subpaths from upstream but keep fork subpaths and order', async () => { + const forkPkg = readPkg(forkPath); + forkPkg.exports = { './fork-only': './dist/fork.js', '.': './dist/custom.js' }; + writePkg(forkPath, forkPkg); + exec('git add -A && git commit -m "add fork exports"', forkPath); + + const upstreamPkg = readPkg(upstreamPath); + upstreamPkg.exports = { + '.': './dist/index.js', + './config': { types: './dist/config.d.ts', import: './dist/config.js' }, + }; + writePkg(upstreamPath, upstreamPkg); + exec('git add -A && git commit -m "add exports"', upstreamPath); + exec('git fetch cella-upstream', forkPath); + + await runPackages(buildConfig({ packageJsonSync: ['dependencies', 'exports'] })); + + const exportsMap = readPkg(forkPath).exports as Record; + // Repointed subpath stays repointed, new subpath arrives with its conditions in upstream order + expect(exportsMap['.']).toBe('./dist/custom.js'); + expect(Object.keys(exportsMap)).toEqual(['./fork-only', '.', './config']); + expect(Object.keys(exportsMap['./config'] as object)).toEqual(['types', 'import']); + }); + + it('should leave exports alone when either side is not a subpath map', async () => { + const forkPkg = readPkg(forkPath); + forkPkg.exports = './dist/index.js'; + writePkg(forkPath, forkPkg); + exec('git add -A && git commit -m "add fork exports"', forkPath); + + const upstreamPkg = readPkg(upstreamPath); + upstreamPkg.exports = { '.': './dist/index.js', './config': './dist/config.js' }; + writePkg(upstreamPath, upstreamPkg); + exec('git add -A && git commit -m "add exports"', upstreamPath); + exec('git fetch cella-upstream', forkPath); + + await runPackages(buildConfig({ packageJsonSync: ['dependencies', 'exports'] })); + + expect(readPkg(forkPath).exports).toBe('./dist/index.js'); + }); + it('should merge pnpm.overrides with add/bump-only logic', async () => { // Fork has pnpm overrides const forkPkg = readPkg(forkPath);