mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bad188babe | ||
|
|
f6ba878b29 | ||
|
|
07fbe5c777 | ||
|
|
c8739b66af | ||
|
|
777a3c947f | ||
|
|
ce3d17ae4c | ||
|
|
2f9dfb6610 | ||
|
|
b862a0575c | ||
|
|
c1fe243a70 | ||
|
|
8065715d4c | ||
|
|
402cc40bb9 | ||
|
|
61eeabb404 | ||
|
|
edbaaf8000 | ||
|
|
68e561e879 |
No files matched your search
@@ -0,0 +1,377 @@
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import { vol } from 'memfs';
|
||||
|
||||
import { CommonContext } from '../context';
|
||||
import { checkLockfileAsync } from '../validateLockfile';
|
||||
import { Client } from '../../vcs/vcs';
|
||||
|
||||
jest.mock('fs');
|
||||
jest.mock('@expo/package-manager', () => ({
|
||||
NPM_LOCK_FILE: 'package-lock.json',
|
||||
YARN_LOCK_FILE: 'yarn.lock',
|
||||
PNPM_LOCK_FILE: 'pnpm-lock.yaml',
|
||||
BUN_LOCK_FILE: 'bun.lockb',
|
||||
BUN_TEXT_LOCK_FILE: 'bun.lock',
|
||||
resolveWorkspaceRoot: jest.fn(() => null),
|
||||
}));
|
||||
|
||||
const { resolveWorkspaceRoot } = jest.requireMock<{
|
||||
resolveWorkspaceRoot: jest.Mock;
|
||||
}>('@expo/package-manager');
|
||||
|
||||
const mockProcessExit = jest.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('process.exit called');
|
||||
});
|
||||
|
||||
function createMockContext(
|
||||
overrides: Partial<{
|
||||
projectDir: string;
|
||||
requiredPackageManager: string | null;
|
||||
isFileIgnoredAsync: (filePath: string) => Promise<boolean>;
|
||||
getRootPathAsync: () => Promise<string>;
|
||||
}> = {}
|
||||
): CommonContext<Platform> {
|
||||
const vcsClient = {
|
||||
isFileIgnoredAsync: overrides.isFileIgnoredAsync ?? (async () => false),
|
||||
getRootPathAsync: overrides.getRootPathAsync ?? (async () => '/project'),
|
||||
} as unknown as Client;
|
||||
|
||||
return {
|
||||
projectDir: overrides.projectDir ?? '/project',
|
||||
requiredPackageManager: overrides.requiredPackageManager ?? null,
|
||||
vcsClient,
|
||||
} as unknown as CommonContext<Platform>;
|
||||
}
|
||||
|
||||
const PACKAGE_JSON_WITH_REACT = JSON.stringify({
|
||||
dependencies: { react: '19.0.4', 'react-native': '~0.79.0' },
|
||||
});
|
||||
|
||||
const NPM_LOCKFILE_SYNCED = JSON.stringify({
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
'node_modules/react': { version: '19.0.4' },
|
||||
'node_modules/react-native': { version: '0.79.4' },
|
||||
},
|
||||
});
|
||||
|
||||
const NPM_LOCKFILE_STALE = JSON.stringify({
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
'node_modules/react': { version: '19.0.0' },
|
||||
'node_modules/react-native': { version: '0.79.4' },
|
||||
},
|
||||
});
|
||||
|
||||
// bun.lock uses JSONC with trailing commas
|
||||
const BUN_LOCKFILE_STALE = `{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"react": "19.0.4",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"react": ["[email protected]", "", {}, "sha512-abc"],
|
||||
},
|
||||
}`;
|
||||
|
||||
const BUN_LOCKFILE_SYNCED = `{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"react": "19.0.4",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"react": ["[email protected]", "", {}, "sha512-abc"],
|
||||
},
|
||||
}`;
|
||||
|
||||
const YARN_LOCKFILE_STALE = `# yarn lockfile v1
|
||||
|
||||
[email protected]:
|
||||
version "19.0.0"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-19.0.0.tgz"
|
||||
`;
|
||||
|
||||
const YARN_LOCKFILE_SYNCED = `# yarn lockfile v1
|
||||
|
||||
[email protected]:
|
||||
version "19.0.4"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-19.0.4.tgz"
|
||||
|
||||
react-native@~0.79.0:
|
||||
version "0.79.4"
|
||||
resolved "https://registry.yarnpkg.com/react-native/-/react-native-0.79.4.tgz"
|
||||
`;
|
||||
|
||||
beforeEach(() => {
|
||||
vol.reset();
|
||||
resolveWorkspaceRoot.mockReturnValue(null);
|
||||
mockProcessExit.mockClear();
|
||||
delete process.env.EAS_BUILD_SKIP_LOCKFILE_CHECK;
|
||||
});
|
||||
|
||||
describe(checkLockfileAsync, () => {
|
||||
it('skips all checks when EAS_BUILD_SKIP_LOCKFILE_CHECK is set', async () => {
|
||||
process.env.EAS_BUILD_SKIP_LOCKFILE_CHECK = '1';
|
||||
// This would normally fail — no lockfile, no package manager
|
||||
vol.fromJSON({ '/project/package.json': '{}' });
|
||||
const ctx = createMockContext({ requiredPackageManager: null });
|
||||
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
|
||||
expect(mockProcessExit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('exits when no package manager is detected', async () => {
|
||||
vol.fromJSON({ '/project/package.json': '{}' });
|
||||
const ctx = createMockContext({ requiredPackageManager: null });
|
||||
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
|
||||
expect(mockProcessExit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('exits when npm is detected but package-lock.json is missing', async () => {
|
||||
vol.fromJSON({ '/project/package.json': '{}' });
|
||||
const ctx = createMockContext({ requiredPackageManager: 'npm' });
|
||||
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
|
||||
expect(mockProcessExit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('does not error when lockfile exists and is in sync', async () => {
|
||||
vol.fromJSON({
|
||||
'/project/package.json': PACKAGE_JSON_WITH_REACT,
|
||||
'/project/package-lock.json': NPM_LOCKFILE_SYNCED,
|
||||
});
|
||||
const ctx = createMockContext({ requiredPackageManager: 'npm' });
|
||||
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
|
||||
expect(mockProcessExit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('exits when lockfile exists but is gitignored', async () => {
|
||||
vol.fromJSON({
|
||||
'/project/package.json': '{}',
|
||||
'/project/yarn.lock': '# yarn lockfile',
|
||||
});
|
||||
const ctx = createMockContext({
|
||||
requiredPackageManager: 'yarn',
|
||||
isFileIgnoredAsync: async () => true,
|
||||
});
|
||||
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
|
||||
expect(mockProcessExit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('exits when conflicting lockfiles from multiple managers exist', async () => {
|
||||
vol.fromJSON({
|
||||
'/project/package.json': '{}',
|
||||
'/project/package-lock.json': NPM_LOCKFILE_SYNCED,
|
||||
'/project/yarn.lock': '# yarn lockfile',
|
||||
});
|
||||
const ctx = createMockContext({ requiredPackageManager: 'npm' });
|
||||
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
|
||||
expect(mockProcessExit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('exits when npm lockfile version does not satisfy package.json specifier', async () => {
|
||||
vol.fromJSON({
|
||||
'/project/package.json': PACKAGE_JSON_WITH_REACT,
|
||||
'/project/package-lock.json': NPM_LOCKFILE_STALE,
|
||||
});
|
||||
const ctx = createMockContext({ requiredPackageManager: 'npm' });
|
||||
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
|
||||
expect(mockProcessExit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('exits when bun.lock version does not satisfy package.json specifier', async () => {
|
||||
vol.fromJSON({
|
||||
'/project/package.json': JSON.stringify({ dependencies: { react: '19.0.4' } }),
|
||||
'/project/bun.lock': BUN_LOCKFILE_STALE,
|
||||
});
|
||||
const ctx = createMockContext({ requiredPackageManager: 'bun' });
|
||||
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
|
||||
expect(mockProcessExit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('does not error when bun.lock is in sync', async () => {
|
||||
vol.fromJSON({
|
||||
'/project/package.json': JSON.stringify({ dependencies: { react: '19.0.4' } }),
|
||||
'/project/bun.lock': BUN_LOCKFILE_SYNCED,
|
||||
});
|
||||
const ctx = createMockContext({ requiredPackageManager: 'bun' });
|
||||
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
|
||||
expect(mockProcessExit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('exits when yarn lockfile version does not satisfy package.json specifier', async () => {
|
||||
vol.fromJSON({
|
||||
'/project/package.json': JSON.stringify({ dependencies: { react: '19.0.4' } }),
|
||||
'/project/yarn.lock': YARN_LOCKFILE_STALE,
|
||||
});
|
||||
const ctx = createMockContext({ requiredPackageManager: 'yarn' });
|
||||
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
|
||||
expect(mockProcessExit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('does not error when yarn lockfile is in sync', async () => {
|
||||
vol.fromJSON({
|
||||
'/project/package.json': PACKAGE_JSON_WITH_REACT,
|
||||
'/project/yarn.lock': YARN_LOCKFILE_SYNCED,
|
||||
});
|
||||
const ctx = createMockContext({ requiredPackageManager: 'yarn' });
|
||||
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
|
||||
expect(mockProcessExit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('finds lockfile at workspace root for monorepos', async () => {
|
||||
vol.fromJSON({
|
||||
'/monorepo/yarn.lock': YARN_LOCKFILE_SYNCED,
|
||||
'/monorepo/packages/app/package.json': PACKAGE_JSON_WITH_REACT,
|
||||
});
|
||||
resolveWorkspaceRoot.mockReturnValue('/monorepo');
|
||||
|
||||
const ctx = createMockContext({
|
||||
projectDir: '/monorepo/packages/app',
|
||||
requiredPackageManager: 'yarn',
|
||||
getRootPathAsync: async () => '/monorepo',
|
||||
});
|
||||
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
|
||||
expect(mockProcessExit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not report a conflict when both bun.lock and bun.lockb are present', async () => {
|
||||
vol.fromJSON({
|
||||
'/project/package.json': JSON.stringify({ dependencies: { react: '19.0.4' } }),
|
||||
'/project/bun.lockb': 'binary',
|
||||
'/project/bun.lock': BUN_LOCKFILE_SYNCED,
|
||||
});
|
||||
const ctx = createMockContext({ requiredPackageManager: 'bun' });
|
||||
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
|
||||
expect(mockProcessExit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips sync check for non-registry specifiers', async () => {
|
||||
vol.fromJSON({
|
||||
'/project/package.json': JSON.stringify({
|
||||
dependencies: {
|
||||
'my-local-pkg': 'file:../local',
|
||||
'my-workspace-pkg': 'workspace:*',
|
||||
react: '19.0.4',
|
||||
},
|
||||
}),
|
||||
'/project/package-lock.json': JSON.stringify({
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
'node_modules/react': { version: '19.0.4' },
|
||||
},
|
||||
}),
|
||||
});
|
||||
const ctx = createMockContext({ requiredPackageManager: 'npm' });
|
||||
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
|
||||
expect(mockProcessExit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('peer dependency conflicts', () => {
|
||||
it('exits when a direct dependency has an unsatisfied peer (npm)', async () => {
|
||||
vol.fromJSON({
|
||||
'/project/package.json': JSON.stringify({
|
||||
dependencies: { react: '19.0.0', 'react-dom': '19.2.4' },
|
||||
}),
|
||||
'/project/package-lock.json': JSON.stringify({
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
'node_modules/react': { version: '19.0.0' },
|
||||
'node_modules/react-dom': {
|
||||
version: '19.2.4',
|
||||
peerDependencies: { react: '^19.2.4' },
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
const ctx = createMockContext({ requiredPackageManager: 'npm' });
|
||||
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
|
||||
expect(mockProcessExit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('exits when a direct dependency has an unsatisfied peer (bun)', async () => {
|
||||
vol.fromJSON({
|
||||
'/project/package.json': JSON.stringify({
|
||||
dependencies: { react: '19.0.0', 'react-dom': '19.2.4' },
|
||||
}),
|
||||
'/project/bun.lock': `{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": { "": { "dependencies": { "react": "19.0.0", "react-dom": "19.2.4" } } },
|
||||
"packages": {
|
||||
"react": ["[email protected]", "", {}, "sha512-abc"],
|
||||
"react-dom": ["[email protected]", "", { "peerDependencies": { "react": "^19.2.4" } }, "sha512-def"],
|
||||
},
|
||||
}`,
|
||||
});
|
||||
const ctx = createMockContext({ requiredPackageManager: 'bun' });
|
||||
await expect(checkLockfileAsync(ctx)).rejects.toThrow('process.exit called');
|
||||
expect(mockProcessExit).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('does not error when peer dependencies are satisfied', async () => {
|
||||
vol.fromJSON({
|
||||
'/project/package.json': JSON.stringify({
|
||||
dependencies: { react: '19.2.4', 'react-dom': '19.2.4' },
|
||||
}),
|
||||
'/project/package-lock.json': JSON.stringify({
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
'node_modules/react': { version: '19.2.4' },
|
||||
'node_modules/react-dom': {
|
||||
version: '19.2.4',
|
||||
peerDependencies: { react: '^19.2.4' },
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
const ctx = createMockContext({ requiredPackageManager: 'npm' });
|
||||
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
|
||||
expect(mockProcessExit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips optional peer dependencies (npm peerDependenciesMeta)', async () => {
|
||||
vol.fromJSON({
|
||||
'/project/package.json': JSON.stringify({
|
||||
dependencies: { '@0no-co/graphql.web': '1.2.0' },
|
||||
}),
|
||||
'/project/package-lock.json': JSON.stringify({
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
'node_modules/@0no-co/graphql.web': {
|
||||
version: '1.2.0',
|
||||
peerDependencies: { graphql: '^14.0.0 || ^15.0.0 || ^16.0.0' },
|
||||
peerDependenciesMeta: { graphql: { optional: true } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
const ctx = createMockContext({ requiredPackageManager: 'npm' });
|
||||
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
|
||||
expect(mockProcessExit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips optional peer dependencies (bun optionalPeers)', async () => {
|
||||
vol.fromJSON({
|
||||
'/project/package.json': JSON.stringify({
|
||||
dependencies: { '@0no-co/graphql.web': '1.2.0' },
|
||||
}),
|
||||
'/project/bun.lock': `{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": { "": { "dependencies": { "@0no-co/graphql.web": "1.2.0" } } },
|
||||
"packages": {
|
||||
"@0no-co/graphql.web": ["@0no-co/[email protected]", "", { "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0" }, "optionalPeers": ["graphql"] }, "sha512-abc"],
|
||||
},
|
||||
}`,
|
||||
});
|
||||
const ctx = createMockContext({ requiredPackageManager: 'bun' });
|
||||
await expect(checkLockfileAsync(ctx)).resolves.not.toThrow();
|
||||
expect(mockProcessExit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -23,6 +23,7 @@ import { evaluateConfigWithEnvVarsAsync } from './evaluateConfigWithEnvVarsAsync
|
||||
import { prepareIosBuildAsync } from './ios/build';
|
||||
import { LocalBuildMode, LocalBuildOptions } from './local';
|
||||
import { ensureExpoDevClientInstalledForDevClientBuildsAsync } from './utils/devClient';
|
||||
import { checkLockfileAsync } from './validateLockfile';
|
||||
import { printBuildResults, printLogsUrls } from './utils/printBuildInfo';
|
||||
import { ensureRepoIsCleanAsync } from './utils/repository';
|
||||
import { Analytics } from '../analytics/AnalyticsManager';
|
||||
@@ -84,6 +85,7 @@ import { Client } from '../vcs/vcs';
|
||||
|
||||
let metroConfigValidated = false;
|
||||
let sdkVersionChecked = false;
|
||||
let lockfileChecked = false;
|
||||
let hasWarnedAboutUsageOverages = false;
|
||||
|
||||
export interface BuildFlags {
|
||||
@@ -414,6 +416,11 @@ async function prepareAndStartBuildAsync({
|
||||
env,
|
||||
});
|
||||
|
||||
if (!lockfileChecked) {
|
||||
await checkLockfileAsync(buildCtx);
|
||||
lockfileChecked = true;
|
||||
}
|
||||
|
||||
if (!hasWarnedAboutUsageOverages && !flags.localBuildOptions.localBuildMode) {
|
||||
hasWarnedAboutUsageOverages = true;
|
||||
Log.newLine();
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import {
|
||||
BUN_LOCK_FILE,
|
||||
BUN_TEXT_LOCK_FILE,
|
||||
NPM_LOCK_FILE,
|
||||
PNPM_LOCK_FILE,
|
||||
YARN_LOCK_FILE,
|
||||
resolveWorkspaceRoot,
|
||||
} from '@expo/package-manager';
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import {
|
||||
LockfileData,
|
||||
findLockfileMismatches,
|
||||
findPeerDependencyConflicts,
|
||||
parseLockfile,
|
||||
} from '@expo/lockfile-utils';
|
||||
import fs from 'fs-extra';
|
||||
import path from 'path';
|
||||
|
||||
import { CommonContext } from './context';
|
||||
import Log from '../log';
|
||||
|
||||
const SKIP_HINT =
|
||||
'To skip this check, set the EAS_BUILD_SKIP_LOCKFILE_CHECK=1 environment variable.';
|
||||
|
||||
interface LockfileInfo {
|
||||
/** Filename of the lockfile (e.g. 'yarn.lock') */
|
||||
filename: string;
|
||||
/** Package manager name for display (e.g. 'yarn') */
|
||||
managerName: string;
|
||||
}
|
||||
|
||||
const LOCKFILE_BY_MANAGER: Record<string, LockfileInfo> = {
|
||||
npm: { filename: NPM_LOCK_FILE, managerName: 'npm' },
|
||||
yarn: { filename: YARN_LOCK_FILE, managerName: 'yarn' },
|
||||
pnpm: { filename: PNPM_LOCK_FILE, managerName: 'pnpm' },
|
||||
bun: { filename: BUN_LOCK_FILE, managerName: 'bun' },
|
||||
};
|
||||
|
||||
const ALL_LOCKFILES: { filename: string; manager: string }[] = [
|
||||
{ filename: NPM_LOCK_FILE, manager: 'npm' },
|
||||
{ filename: YARN_LOCK_FILE, manager: 'yarn' },
|
||||
{ filename: PNPM_LOCK_FILE, manager: 'pnpm' },
|
||||
{ filename: BUN_LOCK_FILE, manager: 'bun' },
|
||||
{ filename: BUN_TEXT_LOCK_FILE, manager: 'bun' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Find the lockfile for the given filename by checking the workspace root first
|
||||
* (for monorepos), then the project directory.
|
||||
*/
|
||||
async function findLockfileAsync(
|
||||
projectDir: string,
|
||||
filename: string
|
||||
): Promise<string | null> {
|
||||
const workspaceRoot = resolveWorkspaceRoot(projectDir);
|
||||
if (workspaceRoot) {
|
||||
const workspaceLockfile = path.join(workspaceRoot, filename);
|
||||
if (await fs.pathExists(workspaceLockfile)) {
|
||||
return workspaceLockfile;
|
||||
}
|
||||
}
|
||||
|
||||
const projectLockfile = path.join(projectDir, filename);
|
||||
if (await fs.pathExists(projectLockfile)) {
|
||||
return projectLockfile;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readLockfileDataAsync(
|
||||
projectDir: string,
|
||||
lockfilePath: string,
|
||||
manager: string
|
||||
): Promise<{ lockfileData: LockfileData; packageJson: Record<string, unknown> } | null> {
|
||||
const packageJsonPath = path.join(projectDir, 'package.json');
|
||||
if (!(await fs.pathExists(packageJsonPath))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const packageJson = await fs.readJson(packageJsonPath);
|
||||
const lockfileContent = await fs.readFile(lockfilePath, 'utf-8');
|
||||
const lockfileData = parseLockfile(lockfileContent, manager, lockfilePath);
|
||||
if (!lockfileData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { lockfileData, packageJson };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-flight lockfile validation for `eas build`. Catches common lockfile
|
||||
* problems before uploading to the builder.
|
||||
*/
|
||||
export async function checkLockfileAsync<T extends Platform>(
|
||||
ctx: CommonContext<T>
|
||||
): Promise<void> {
|
||||
if (process.env.EAS_BUILD_SKIP_LOCKFILE_CHECK) {
|
||||
Log.warn('Skipping lockfile validation (EAS_BUILD_SKIP_LOCKFILE_CHECK is set).');
|
||||
return;
|
||||
}
|
||||
|
||||
const { projectDir, requiredPackageManager, vcsClient } = ctx;
|
||||
|
||||
// --- 1. Lockfile existence ---
|
||||
if (!requiredPackageManager) {
|
||||
Log.error('No lockfile found in the project directory.');
|
||||
Log.error(
|
||||
'A lockfile is required to ensure deterministic dependency installation on the EAS builder.'
|
||||
);
|
||||
Log.error(
|
||||
'Run your package manager\'s install command (e.g. "npm install") to generate one, then commit it to version control.'
|
||||
);
|
||||
Log.error(SKIP_HINT);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const lockInfo = LOCKFILE_BY_MANAGER[requiredPackageManager];
|
||||
if (!lockInfo) {
|
||||
// Unknown package manager — nothing we can validate
|
||||
return;
|
||||
}
|
||||
|
||||
// For bun, also check the text lockfile variant
|
||||
const lockfilePath =
|
||||
requiredPackageManager === 'bun'
|
||||
? (await findLockfileAsync(projectDir, BUN_LOCK_FILE)) ??
|
||||
(await findLockfileAsync(projectDir, BUN_TEXT_LOCK_FILE))
|
||||
: await findLockfileAsync(projectDir, lockInfo.filename);
|
||||
|
||||
if (!lockfilePath) {
|
||||
Log.error(
|
||||
`${lockInfo.managerName} lockfile (${lockInfo.filename}) not found.`
|
||||
);
|
||||
Log.error(
|
||||
`Run "${requiredPackageManager} install" to generate a lockfile, then commit it to version control.`
|
||||
);
|
||||
Log.error(SKIP_HINT);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- 2. Lockfile tracked in VCS ---
|
||||
const rootDir = path.normalize(await vcsClient.getRootPathAsync());
|
||||
const relativeLockfilePath = path.relative(rootDir, lockfilePath);
|
||||
if (await vcsClient.isFileIgnoredAsync(relativeLockfilePath)) {
|
||||
Log.error(
|
||||
`${lockInfo.managerName} lockfile (${relativeLockfilePath}) is ignored by your version control system and won't be uploaded to the EAS builder.`
|
||||
);
|
||||
Log.error(
|
||||
'Remove the lockfile from .gitignore (and .easignore, if applicable), then commit it.'
|
||||
);
|
||||
Log.error(SKIP_HINT);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- 3. Conflicting lockfiles ---
|
||||
const foundManagers = new Set<string>();
|
||||
for (const { filename, manager } of ALL_LOCKFILES) {
|
||||
if (await findLockfileAsync(projectDir, filename)) {
|
||||
foundManagers.add(manager);
|
||||
}
|
||||
}
|
||||
|
||||
if (foundManagers.size > 1) {
|
||||
const managerList = [...foundManagers].sort().join(', ');
|
||||
Log.error(`Conflicting lockfiles detected from multiple package managers: ${managerList}.`);
|
||||
Log.error(
|
||||
'Having lockfiles from multiple package managers can cause install failures on the EAS builder. Remove the lockfiles you do not need and commit the change.'
|
||||
);
|
||||
Log.error(SKIP_HINT);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- 4 & 5. Parse lockfile for dependency sync and peer dep checks ---
|
||||
// For bun, prefer the text lockfile for parsing (bun.lockb is binary)
|
||||
const parsableLockfilePath =
|
||||
requiredPackageManager === 'bun'
|
||||
? (await findLockfileAsync(projectDir, BUN_TEXT_LOCK_FILE)) ?? lockfilePath
|
||||
: lockfilePath;
|
||||
|
||||
const parsed = await readLockfileDataAsync(
|
||||
projectDir,
|
||||
parsableLockfilePath,
|
||||
requiredPackageManager
|
||||
);
|
||||
|
||||
if (!parsed) {
|
||||
return; // Can't parse — skip remaining checks
|
||||
}
|
||||
|
||||
// --- 4. Lockfile out of sync ---
|
||||
const mismatches = findLockfileMismatches(parsed.packageJson, parsed.lockfileData);
|
||||
|
||||
if (mismatches.length > 0) {
|
||||
Log.error('Lockfile is out of sync with package.json:');
|
||||
for (const m of mismatches) {
|
||||
Log.error(
|
||||
` ${m.name}: package.json requires ${m.specifier}, but lockfile has ${m.lockedVersion}`
|
||||
);
|
||||
}
|
||||
Log.error(
|
||||
`Run "${requiredPackageManager} install" to update your lockfile, then commit the changes.`
|
||||
);
|
||||
Log.error(SKIP_HINT);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- 5. Peer dependency conflicts ---
|
||||
const peerConflicts = findPeerDependencyConflicts(parsed.packageJson, parsed.lockfileData);
|
||||
|
||||
if (peerConflicts.length > 0) {
|
||||
Log.error('Peer dependency conflicts detected:');
|
||||
for (const c of peerConflicts) {
|
||||
Log.error(
|
||||
` ${c.source}@${c.sourceVersion} requires peer ${c.peer}@"${c.peerRange}", but ${c.peer}@${c.installedVersion} is installed`
|
||||
);
|
||||
}
|
||||
Log.error(
|
||||
'Update the conflicting packages to compatible versions, then run ' +
|
||||
`"${requiredPackageManager} install" and commit the changes.`
|
||||
);
|
||||
Log.error(SKIP_HINT);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Config } from '@jest/types';
|
||||
|
||||
import baseConfig from '../../jest/jest.shared.config';
|
||||
|
||||
const config: Config.InitialOptions = {
|
||||
...baseConfig,
|
||||
rootDir: 'src',
|
||||
testMatch: ['**/__tests__/*.test.ts'],
|
||||
clearMocks: true,
|
||||
setupFilesAfterEnv: ['<rootDir>/../jest/setup-tests.ts'],
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,5 @@
|
||||
if (process.env.NODE_ENV !== 'test') {
|
||||
throw new Error("NODE_ENV environment variable must be set to 'test'.");
|
||||
}
|
||||
|
||||
// Always mock:
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "@expo/lockfile-utils",
|
||||
"version": "18.0.1",
|
||||
"description": "Parse npm, yarn, pnpm, and bun lockfiles and validate them against package.json for version mismatches and peer dependency conflicts",
|
||||
"bugs": "https://github.com/expo/eas-cli/issues",
|
||||
"license": "MIT",
|
||||
"author": "Expo <[email protected]>",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/expo/eas-cli.git",
|
||||
"directory": "packages/lockfile-utils"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"start": "yarn watch",
|
||||
"watch": "tsc --watch --preserveWatchOutput",
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc",
|
||||
"prepack": "rm -rf dist *.tsbuildinfo && tsc -p tsconfig.build.json",
|
||||
"test": "jest --config jest.config.ts",
|
||||
"test:watch": "jest --config jest.config.ts --watch",
|
||||
"clean": "rm -rf node_modules dist coverage *.tsbuildinfo"
|
||||
},
|
||||
"dependencies": {
|
||||
"semver": "^7.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "20.14.2",
|
||||
"@types/semver": "^7.5.8",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.1.4",
|
||||
"typescript": "^5.5.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import {
|
||||
parseLockfile,
|
||||
parseNpmLock,
|
||||
parseYarnLock,
|
||||
parsePnpmLock,
|
||||
parseBunLock,
|
||||
} from '../parsers';
|
||||
|
||||
const NPM_LOCKFILE = JSON.stringify({
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
'node_modules/react': { version: '19.0.4' },
|
||||
'node_modules/react-native': { version: '0.79.4' },
|
||||
'node_modules/react/node_modules/loose-envify': { version: '1.4.0' },
|
||||
},
|
||||
});
|
||||
|
||||
const NPM_LOCKFILE_WITH_PEERS = JSON.stringify({
|
||||
lockfileVersion: 3,
|
||||
packages: {
|
||||
'node_modules/react': { version: '19.0.0' },
|
||||
'node_modules/react-dom': {
|
||||
version: '19.2.4',
|
||||
peerDependencies: { react: '^19.2.4' },
|
||||
},
|
||||
'node_modules/@0no-co/graphql.web': {
|
||||
version: '1.2.0',
|
||||
peerDependencies: { graphql: '^14.0.0 || ^15.0.0 || ^16.0.0' },
|
||||
peerDependenciesMeta: { graphql: { optional: true } },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const YARN_LOCKFILE = `# yarn lockfile v1
|
||||
|
||||
[email protected]:
|
||||
version "19.0.4"
|
||||
resolved "https://registry.yarnpkg.com/react/-/react-19.0.4.tgz"
|
||||
|
||||
react-native@~0.79.0:
|
||||
version "0.79.4"
|
||||
resolved "https://registry.yarnpkg.com/react-native/-/react-native-0.79.4.tgz"
|
||||
`;
|
||||
|
||||
const BUN_LOCKFILE = `{
|
||||
"lockfileVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"react": "19.0.4",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"react": ["[email protected]", "", {}, "sha512-abc"],
|
||||
"react-dom": ["[email protected]", "", { "peerDependencies": { "react": "^19.2.4" } }, "sha512-def"],
|
||||
"@0no-co/graphql.web": ["@0no-co/[email protected]", "", { "peerDependencies": { "graphql": "^16.0.0" }, "optionalPeers": ["graphql"] }, "sha512-ghi"],
|
||||
},
|
||||
}`;
|
||||
|
||||
const PNPM_LOCKFILE = `lockfileVersion: '9.0'
|
||||
|
||||
packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-abc}
|
||||
@types/[email protected]:
|
||||
resolution: {integrity: sha512-def}
|
||||
`;
|
||||
|
||||
describe('parseNpmLock', () => {
|
||||
it('parses top-level packages', () => {
|
||||
const result = parseNpmLock(NPM_LOCKFILE);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.packages.get('react')?.version).toBe('19.0.4');
|
||||
expect(result!.packages.get('react-native')?.version).toBe('0.79.4');
|
||||
});
|
||||
|
||||
it('skips nested node_modules (transitive deps)', () => {
|
||||
const result = parseNpmLock(NPM_LOCKFILE);
|
||||
expect(result!.packages.has('loose-envify')).toBe(false);
|
||||
});
|
||||
|
||||
it('parses peer dependencies and optional peers', () => {
|
||||
const result = parseNpmLock(NPM_LOCKFILE_WITH_PEERS);
|
||||
const reactDom = result!.packages.get('react-dom');
|
||||
expect(reactDom?.peerDependencies).toEqual({ react: '^19.2.4' });
|
||||
expect(reactDom?.optionalPeers).toBeUndefined();
|
||||
|
||||
const graphqlWeb = result!.packages.get('@0no-co/graphql.web');
|
||||
expect(graphqlWeb?.optionalPeers?.has('graphql')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns null for invalid JSON', () => {
|
||||
expect(parseNpmLock('not json')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseYarnLock', () => {
|
||||
it('parses package versions', () => {
|
||||
const result = parseYarnLock(YARN_LOCKFILE);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.packages.get('react')?.version).toBe('19.0.4');
|
||||
expect(result!.packages.get('react-native')?.version).toBe('0.79.4');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parsePnpmLock', () => {
|
||||
it('parses package versions including scoped packages', () => {
|
||||
const result = parsePnpmLock(PNPM_LOCKFILE);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.packages.get('react')?.version).toBe('19.0.4');
|
||||
expect(result!.packages.get('@types/react')?.version).toBe('19.0.4');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseBunLock', () => {
|
||||
it('parses package versions from JSONC format', () => {
|
||||
const result = parseBunLock(BUN_LOCKFILE);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.packages.get('react')?.version).toBe('19.0.4');
|
||||
});
|
||||
|
||||
it('parses peer dependencies and optional peers', () => {
|
||||
const result = parseBunLock(BUN_LOCKFILE);
|
||||
const reactDom = result!.packages.get('react-dom');
|
||||
expect(reactDom?.peerDependencies).toEqual({ react: '^19.2.4' });
|
||||
|
||||
const graphqlWeb = result!.packages.get('@0no-co/graphql.web');
|
||||
expect(graphqlWeb?.optionalPeers?.has('graphql')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns null for invalid content', () => {
|
||||
expect(parseBunLock('not json at all {')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseLockfile', () => {
|
||||
it('dispatches to the correct parser by manager', () => {
|
||||
expect(parseLockfile(NPM_LOCKFILE, 'npm', 'package-lock.json')).not.toBeNull();
|
||||
expect(parseLockfile(YARN_LOCKFILE, 'yarn', 'yarn.lock')).not.toBeNull();
|
||||
expect(parseLockfile(PNPM_LOCKFILE, 'pnpm', 'pnpm-lock.yaml')).not.toBeNull();
|
||||
expect(parseLockfile(BUN_LOCKFILE, 'bun', 'bun.lock')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for binary bun.lockb', () => {
|
||||
expect(parseLockfile('binary', 'bun', 'bun.lockb')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for unknown manager', () => {
|
||||
expect(parseLockfile('{}', 'deno', 'deno.lock')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { findLockfileMismatches, findPeerDependencyConflicts } from '../validators';
|
||||
import { LockfileData, PackageInfo } from '../types';
|
||||
|
||||
function makeLockfileData(packages: Record<string, PackageInfo>): LockfileData {
|
||||
return { packages: new Map(Object.entries(packages)) };
|
||||
}
|
||||
|
||||
describe('findLockfileMismatches', () => {
|
||||
it('returns empty when lockfile is in sync', () => {
|
||||
const packageJson = { dependencies: { react: '19.0.4', 'react-native': '~0.79.0' } };
|
||||
const lockfile = makeLockfileData({
|
||||
react: { version: '19.0.4' },
|
||||
'react-native': { version: '0.79.4' },
|
||||
});
|
||||
expect(findLockfileMismatches(packageJson, lockfile)).toEqual([]);
|
||||
});
|
||||
|
||||
it('detects version mismatch', () => {
|
||||
const packageJson = { dependencies: { react: '19.0.4' } };
|
||||
const lockfile = makeLockfileData({ react: { version: '19.0.0' } });
|
||||
const mismatches = findLockfileMismatches(packageJson, lockfile);
|
||||
expect(mismatches).toHaveLength(1);
|
||||
expect(mismatches[0]).toEqual({
|
||||
name: 'react',
|
||||
specifier: '19.0.4',
|
||||
lockedVersion: '19.0.0',
|
||||
});
|
||||
});
|
||||
|
||||
it('checks devDependencies and optionalDependencies', () => {
|
||||
const packageJson = {
|
||||
devDependencies: { typescript: '^5.5.0' },
|
||||
optionalDependencies: { fsevents: '^2.3.0' },
|
||||
};
|
||||
const lockfile = makeLockfileData({
|
||||
typescript: { version: '5.5.4' },
|
||||
fsevents: { version: '2.3.3' },
|
||||
});
|
||||
expect(findLockfileMismatches(packageJson, lockfile)).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips non-registry specifiers', () => {
|
||||
const packageJson = {
|
||||
dependencies: {
|
||||
'my-local': 'file:../local',
|
||||
'my-workspace': 'workspace:*',
|
||||
'my-git': 'git+https://github.com/foo/bar.git',
|
||||
'my-github': 'github:foo/bar',
|
||||
'my-latest': 'latest',
|
||||
},
|
||||
};
|
||||
const lockfile = makeLockfileData({});
|
||||
expect(findLockfileMismatches(packageJson, lockfile)).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips packages not found in lockfile', () => {
|
||||
const packageJson = { dependencies: { 'new-dep': '^1.0.0' } };
|
||||
const lockfile = makeLockfileData({});
|
||||
expect(findLockfileMismatches(packageJson, lockfile)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findPeerDependencyConflicts', () => {
|
||||
it('returns empty when peers are satisfied', () => {
|
||||
const packageJson = { dependencies: { react: '19.2.4', 'react-dom': '19.2.4' } };
|
||||
const lockfile = makeLockfileData({
|
||||
react: { version: '19.2.4' },
|
||||
'react-dom': {
|
||||
version: '19.2.4',
|
||||
peerDependencies: { react: '^19.2.4' },
|
||||
},
|
||||
});
|
||||
expect(findPeerDependencyConflicts(packageJson, lockfile)).toEqual([]);
|
||||
});
|
||||
|
||||
it('detects unsatisfied peer dependency', () => {
|
||||
const packageJson = { dependencies: { react: '19.0.0', 'react-dom': '19.2.4' } };
|
||||
const lockfile = makeLockfileData({
|
||||
react: { version: '19.0.0' },
|
||||
'react-dom': {
|
||||
version: '19.2.4',
|
||||
peerDependencies: { react: '^19.2.4' },
|
||||
},
|
||||
});
|
||||
const conflicts = findPeerDependencyConflicts(packageJson, lockfile);
|
||||
expect(conflicts).toHaveLength(1);
|
||||
expect(conflicts[0]).toEqual({
|
||||
source: 'react-dom',
|
||||
sourceVersion: '19.2.4',
|
||||
peer: 'react',
|
||||
peerRange: '^19.2.4',
|
||||
installedVersion: '19.0.0',
|
||||
});
|
||||
});
|
||||
|
||||
it('skips optional peers', () => {
|
||||
const packageJson = { dependencies: { '@0no-co/graphql.web': '1.2.0' } };
|
||||
const lockfile = makeLockfileData({
|
||||
'@0no-co/graphql.web': {
|
||||
version: '1.2.0',
|
||||
peerDependencies: { graphql: '^14.0.0 || ^15.0.0 || ^16.0.0' },
|
||||
optionalPeers: new Set(['graphql']),
|
||||
},
|
||||
});
|
||||
expect(findPeerDependencyConflicts(packageJson, lockfile)).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips peers not installed in lockfile', () => {
|
||||
const packageJson = { dependencies: { 'react-dom': '19.2.4' } };
|
||||
const lockfile = makeLockfileData({
|
||||
'react-dom': {
|
||||
version: '19.2.4',
|
||||
peerDependencies: { react: '^19.2.4' },
|
||||
},
|
||||
});
|
||||
expect(findPeerDependencyConflicts(packageJson, lockfile)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
export { parseLockfile, parseNpmLock, parseYarnLock, parsePnpmLock, parseBunLock } from './parsers';
|
||||
export { findLockfileMismatches, findPeerDependencyConflicts } from './validators';
|
||||
export type {
|
||||
PackageInfo,
|
||||
LockfileData,
|
||||
DependencyMismatch,
|
||||
PeerDependencyConflict,
|
||||
} from './types';
|
||||
@@ -0,0 +1,154 @@
|
||||
import { LockfileData, PackageInfo } from './types';
|
||||
|
||||
export function parseNpmLock(content: string): LockfileData | null {
|
||||
try {
|
||||
const lockfile = JSON.parse(content);
|
||||
const rawPackages: Record<
|
||||
string,
|
||||
{
|
||||
version?: string;
|
||||
peerDependencies?: Record<string, string>;
|
||||
peerDependenciesMeta?: Record<string, { optional?: boolean }>;
|
||||
}
|
||||
> = lockfile.packages ?? {};
|
||||
const packages = new Map<string, PackageInfo>();
|
||||
for (const [key, value] of Object.entries(rawPackages)) {
|
||||
if (!key.startsWith('node_modules/')) {
|
||||
continue;
|
||||
}
|
||||
const rest = key.slice('node_modules/'.length);
|
||||
// Skip nested node_modules (transitive deps)
|
||||
if (rest.includes('node_modules/')) {
|
||||
continue;
|
||||
}
|
||||
if (value.version) {
|
||||
const optionalPeers = new Set<string>();
|
||||
if (value.peerDependenciesMeta) {
|
||||
for (const [peer, meta] of Object.entries(value.peerDependenciesMeta)) {
|
||||
if (meta.optional) {
|
||||
optionalPeers.add(peer);
|
||||
}
|
||||
}
|
||||
}
|
||||
packages.set(rest, {
|
||||
version: value.version,
|
||||
peerDependencies: value.peerDependencies,
|
||||
optionalPeers: optionalPeers.size > 0 ? optionalPeers : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
return { packages };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseYarnLock(content: string): LockfileData | null {
|
||||
try {
|
||||
const packages = new Map<string, PackageInfo>();
|
||||
const lines = content.split('\n');
|
||||
let currentPackage: string | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
// Entry header: not indented, not a comment, ends with ':'
|
||||
if (line.length > 0 && !line.startsWith(' ') && !line.startsWith('#') && line.endsWith(':')) {
|
||||
// Examples:
|
||||
// yarn v1: react@^19.0.0: or "react@^19.0.0":
|
||||
// yarn v2+: "react@npm:^19.0.0":
|
||||
const entry = line.replace(/^"/, '').replace(/:$/, '').replace(/"$/, '');
|
||||
const atIndex = entry.indexOf('@', entry.startsWith('@') ? 1 : 0);
|
||||
if (atIndex > 0) {
|
||||
currentPackage = entry.slice(0, atIndex);
|
||||
}
|
||||
}
|
||||
|
||||
// Version line (indented): ` version "19.0.0"` or ` version: 19.0.0`
|
||||
if (currentPackage) {
|
||||
const versionMatch = line.match(/^\s+version:?\s+"?([^"\s]+)"?/);
|
||||
if (versionMatch) {
|
||||
if (!packages.has(currentPackage)) {
|
||||
packages.set(currentPackage, { version: versionMatch[1] });
|
||||
}
|
||||
currentPackage = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
// yarn.lock doesn't include peer dependency info
|
||||
return { packages };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function parsePnpmLock(content: string): LockfileData | null {
|
||||
try {
|
||||
const packages = new Map<string, PackageInfo>();
|
||||
// Match entries in the packages section like:
|
||||
// [email protected]: or @types/[email protected]:
|
||||
const packageRegex = /^ {2}((?:@[\w.-]+\/)?[\w.-]+)@(\d+[^:\s]*?):/gm;
|
||||
let match;
|
||||
while ((match = packageRegex.exec(content)) !== null) {
|
||||
if (!packages.has(match[1])) {
|
||||
packages.set(match[1], { version: match[2] });
|
||||
}
|
||||
}
|
||||
// pnpm-lock.yaml doesn't readily expose peer deps in a parseable way without YAML
|
||||
return { packages };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseBunLock(content: string): LockfileData | null {
|
||||
try {
|
||||
// bun.lock is JSONC (has trailing commas) — strip them for JSON.parse
|
||||
const jsonContent = content.replace(/,(\s*[}\]])/g, '$1');
|
||||
const lockfile = JSON.parse(jsonContent);
|
||||
const rawPackages: Record<string, unknown[]> = lockfile.packages ?? {};
|
||||
const packages = new Map<string, PackageInfo>();
|
||||
for (const [name, value] of Object.entries(rawPackages)) {
|
||||
// Format: "react": ["[email protected]", "", { peerDependencies: {...}, optionalPeers: [...] }, "sha512-..."]
|
||||
if (Array.isArray(value) && typeof value[0] === 'string') {
|
||||
const entry: string = value[0];
|
||||
const atIndex = entry.lastIndexOf('@');
|
||||
if (atIndex > 0) {
|
||||
const meta = (typeof value[2] === 'object' && value[2] !== null ? value[2] : {}) as {
|
||||
peerDependencies?: Record<string, string>;
|
||||
optionalPeers?: string[];
|
||||
};
|
||||
packages.set(name, {
|
||||
version: entry.slice(atIndex + 1),
|
||||
peerDependencies: meta.peerDependencies,
|
||||
optionalPeers: meta.optionalPeers ? new Set(meta.optionalPeers) : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return { packages };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseLockfile(
|
||||
content: string,
|
||||
manager: string,
|
||||
lockfilePath: string
|
||||
): LockfileData | null {
|
||||
switch (manager) {
|
||||
case 'npm':
|
||||
return parseNpmLock(content);
|
||||
case 'yarn':
|
||||
return parseYarnLock(content);
|
||||
case 'pnpm':
|
||||
return parsePnpmLock(content);
|
||||
case 'bun':
|
||||
// bun.lockb is binary — can only parse the text variant
|
||||
if (lockfilePath.endsWith('.lockb')) {
|
||||
return null;
|
||||
}
|
||||
return parseBunLock(content);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export interface PackageInfo {
|
||||
version: string;
|
||||
peerDependencies?: Record<string, string>;
|
||||
optionalPeers?: Set<string>;
|
||||
}
|
||||
|
||||
export interface LockfileData {
|
||||
packages: Map<string, PackageInfo>;
|
||||
}
|
||||
|
||||
export interface DependencyMismatch {
|
||||
name: string;
|
||||
specifier: string;
|
||||
lockedVersion: string;
|
||||
}
|
||||
|
||||
export interface PeerDependencyConflict {
|
||||
/** Package that declares the peer dependency */
|
||||
source: string;
|
||||
sourceVersion: string;
|
||||
/** The peer dependency that is not satisfied */
|
||||
peer: string;
|
||||
peerRange: string;
|
||||
/** The actual version of the peer in the lockfile */
|
||||
installedVersion: string;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import semver from 'semver';
|
||||
|
||||
import { DependencyMismatch, LockfileData, PeerDependencyConflict } from './types';
|
||||
|
||||
function isNonRegistrySpecifier(specifier: string): boolean {
|
||||
return (
|
||||
specifier.startsWith('file:') ||
|
||||
specifier.startsWith('link:') ||
|
||||
specifier.startsWith('workspace:') ||
|
||||
specifier.startsWith('git:') ||
|
||||
specifier.startsWith('git+') ||
|
||||
specifier.startsWith('github:') ||
|
||||
specifier.startsWith('http:') ||
|
||||
specifier.startsWith('https:') ||
|
||||
specifier.startsWith('portal:') ||
|
||||
specifier === '*' ||
|
||||
specifier === 'latest' ||
|
||||
specifier === 'next'
|
||||
);
|
||||
}
|
||||
|
||||
export function findLockfileMismatches(
|
||||
packageJson: Record<string, unknown>,
|
||||
lockfileData: LockfileData
|
||||
): DependencyMismatch[] {
|
||||
const allDeps: Record<string, string> = {
|
||||
...(packageJson.dependencies as Record<string, string> | undefined),
|
||||
...(packageJson.devDependencies as Record<string, string> | undefined),
|
||||
...(packageJson.optionalDependencies as Record<string, string> | undefined),
|
||||
};
|
||||
|
||||
const mismatches: DependencyMismatch[] = [];
|
||||
for (const [name, specifier] of Object.entries(allDeps)) {
|
||||
if (isNonRegistrySpecifier(specifier)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const pkg = lockfileData.packages.get(name);
|
||||
if (!pkg) {
|
||||
continue; // Package not found in lockfile — may be a new dep or hoisted differently
|
||||
}
|
||||
|
||||
if (!semver.satisfies(pkg.version, specifier)) {
|
||||
mismatches.push({ name, specifier, lockedVersion: pkg.version });
|
||||
}
|
||||
}
|
||||
|
||||
return mismatches;
|
||||
}
|
||||
|
||||
export function findPeerDependencyConflicts(
|
||||
packageJson: Record<string, unknown>,
|
||||
lockfileData: LockfileData
|
||||
): PeerDependencyConflict[] {
|
||||
// Check peer deps of direct dependencies only (most common source of build failures)
|
||||
const directDeps: Record<string, string> = {
|
||||
...(packageJson.dependencies as Record<string, string> | undefined),
|
||||
...(packageJson.devDependencies as Record<string, string> | undefined),
|
||||
};
|
||||
|
||||
const conflicts: PeerDependencyConflict[] = [];
|
||||
for (const depName of Object.keys(directDeps)) {
|
||||
const pkg = lockfileData.packages.get(depName);
|
||||
if (!pkg?.peerDependencies) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [peer, peerRange] of Object.entries(pkg.peerDependencies)) {
|
||||
// Skip optional peers
|
||||
if (pkg.optionalPeers?.has(peer)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const peerPkg = lockfileData.packages.get(peer);
|
||||
if (!peerPkg) {
|
||||
continue; // Peer not installed — might be optional or provided by a parent
|
||||
}
|
||||
|
||||
if (!semver.satisfies(peerPkg.version, peerRange)) {
|
||||
conflicts.push({
|
||||
source: depName,
|
||||
sourceVersion: pkg.version,
|
||||
peer,
|
||||
peerRange,
|
||||
installedVersion: peerPkg.version,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return conflicts;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["src/**/__mocks__/**/*.ts", "src/**/__tests__/**/*.ts"]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"moduleResolution": "node",
|
||||
"target": "es2022",
|
||||
"module": "commonjs",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"declaration": true,
|
||||
"composite": true,
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
@@ -3270,6 +3270,21 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@expo/lockfile-utils@npm:18.0.1, @expo/lockfile-utils@workspace:packages/lockfile-utils":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/lockfile-utils@workspace:packages/lockfile-utils"
|
||||
dependencies:
|
||||
"@types/jest": "npm:^29.5.12"
|
||||
"@types/node": "npm:20.14.2"
|
||||
"@types/semver": "npm:^7.5.8"
|
||||
jest: "npm:^29.7.0"
|
||||
semver: "npm:^7.6.0"
|
||||
ts-jest: "npm:^29.1.4"
|
||||
typescript: "npm:^5.5.4"
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@expo/logger@npm:18.0.1, @expo/logger@workspace:packages/logger":
|
||||
"@expo/logger@npm:18.5.0, @expo/logger@workspace:packages/logger":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@expo/logger@workspace:packages/logger"
|
||||
@@ -11006,6 +11021,7 @@ __metadata:
|
||||
"@expo/eas-json": "npm:18.8.0"
|
||||
"@expo/env": "npm:^1.0.0"
|
||||
"@expo/json-file": "npm:8.3.3"
|
||||
"@expo/lockfile-utils": "npm:18.0.1"
|
||||
"@expo/logger": "npm:18.5.0"
|
||||
"@expo/multipart-body-parser": "npm:2.0.0"
|
||||
"@expo/osascript": "npm:2.1.4"
|
||||
|
||||
Reference in new issue
Block a user