Skip to content
Open
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
132 changes: 121 additions & 11 deletions packages/amplify-cli-core/src/__tests__/packageManager.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,24 @@
import * as path from 'path';
import which from 'which';
import { getPackageManager } from '../utils';
import * as shellUtils from '../utils/shell-utils';

jest.mock('which');
jest.mock('../utils/shell-utils');

describe('packageManager tests', () => {
const baseDirectory = path.join(__dirname, 'testFiles');
const which_mock = which as jest.Mocked<typeof which>;

beforeEach(() => jest.clearAllMocks());
const shellUtils_mock = shellUtils as jest.Mocked<typeof shellUtils>;

beforeEach(() => {
jest.resetAllMocks();
// Default to returning undefined (executable not found)
// Tests that need specific executables should set them explicitly
(which_mock.sync as jest.Mock).mockReturnValue(undefined);
// Mock yarn --version to return a valid version
shellUtils_mock.execWithOutputAsString.mockResolvedValue('4.0.0');
});

test('returns null when no package.json found', async () => {
const testDirectory = path.join(baseDirectory, 'packageManager-null');
Expand All @@ -19,31 +29,36 @@ describe('packageManager tests', () => {
});

test('detects yarn2 correctly', async () => {
which_mock.sync.mockReturnValue('/path/to/yarn');
(which_mock.sync as jest.Mock).mockImplementation((executable: string) => {
if (executable === 'yarn') return '/path/to/yarn';
return undefined;
});

const testDirectory = path.join(baseDirectory, 'packageManager-yarn2');

const packageManager = await getPackageManager(testDirectory);

expect(which_mock.sync).toBeCalledTimes(1);
expect(packageManager).toBeDefined();
expect(packageManager?.packageManager).toEqual('yarn');
expect(packageManager?.version?.major).toBeGreaterThanOrEqual(2);
});

test('detects yarn correctly', async () => {
which_mock.sync.mockReturnValue('/path/to/yarn');
(which_mock.sync as jest.Mock).mockImplementation((executable: string) => {
if (executable === 'yarn') return '/path/to/yarn';
return undefined;
});

const testDirectory = path.join(baseDirectory, 'packageManager-yarn');

const packageManager = await getPackageManager(testDirectory);

expect(which_mock.sync).toBeCalledTimes(1);
expect(packageManager).toBeDefined();
expect(packageManager!.packageManager).toEqual('yarn');
});

test('detects npm correctly', async () => {
// No executables in PATH - should fall back to npm via lock file
const testDirectory = path.join(baseDirectory, 'packageManager-npm');

const packageManager = await getPackageManager(testDirectory);
Expand All @@ -53,26 +68,121 @@ describe('packageManager tests', () => {
});

test('detects yarn fallback correctly when yarn in path', async () => {
which_mock.sync.mockReturnValue('/path/to/yarn');
(which_mock.sync as jest.Mock).mockImplementation((executable: string) => {
if (executable === 'yarn') return '/path/to/yarn';
return undefined;
});

const testDirectory = path.join(baseDirectory, 'packageManager-fallback');

const packageManager = await getPackageManager(testDirectory);

expect(which_mock.sync).toBeCalledTimes(1);
expect(packageManager).toBeDefined();
expect(packageManager!.packageManager).toEqual('yarn');
});

test('detects npm fallback correctly when yarn is not in path', async () => {
(which_mock.sync as any).mockReturnValue(undefined);

// No executables in PATH (already set in beforeEach)
const testDirectory = path.join(baseDirectory, 'packageManager-fallback');

const packageManager = await getPackageManager(testDirectory);

expect(which_mock.sync).toBeCalledTimes(2);
expect(packageManager).toBeDefined();
expect(packageManager!.packageManager).toEqual('npm');
});

describe('packageManager field detection (corepack convention)', () => {
test('detects pnpm from packageManager field in package.json', async () => {
(which_mock.sync as jest.Mock).mockImplementation((executable: string) => {
if (executable === 'pnpm') return '/path/to/pnpm';
return undefined;
});

const testDirectory = path.join(baseDirectory, 'packageManager-pnpm-field');

const packageManager = await getPackageManager(testDirectory);

expect(packageManager).toBeDefined();
expect(packageManager!.packageManager).toEqual('pnpm');
});

test('detects npm from packageManager field in package.json', async () => {
(which_mock.sync as jest.Mock).mockImplementation((executable: string) => {
if (executable === 'npm') return '/path/to/npm';
return undefined;
});

const testDirectory = path.join(baseDirectory, 'packageManager-npm-field');

const packageManager = await getPackageManager(testDirectory);

expect(packageManager).toBeDefined();
expect(packageManager!.packageManager).toEqual('npm');
});

test('detects yarn from packageManager field in package.json', async () => {
(which_mock.sync as jest.Mock).mockImplementation((executable: string) => {
if (executable === 'yarn') return '/path/to/yarn';
return undefined;
});

const testDirectory = path.join(baseDirectory, 'packageManager-yarn-field');

const packageManager = await getPackageManager(testDirectory);

expect(packageManager).toBeDefined();
expect(packageManager!.packageManager).toEqual('yarn');
});

test('detects packageManager field from parent directory in hierarchy', async () => {
(which_mock.sync as jest.Mock).mockImplementation((executable: string) => {
if (executable === 'pnpm') return '/path/to/pnpm';
return undefined;
});

// subdir has package.json but no packageManager field
// parent directory has packageManager: pnpm@10.17.0
const testDirectory = path.join(baseDirectory, 'packageManager-hierarchy', 'subdir');

const packageManager = await getPackageManager(testDirectory);

expect(packageManager).toBeDefined();
expect(packageManager!.packageManager).toEqual('pnpm');
});

test('packageManager field takes precedence over lock files', async () => {
(which_mock.sync as jest.Mock).mockImplementation((executable: string) => {
if (executable === 'pnpm') return '/path/to/pnpm';
if (executable === 'yarn') return '/path/to/yarn';
return undefined;
});

// Directory has yarn.lock but package.json has packageManager: pnpm@10.17.0
const testDirectory = path.join(baseDirectory, 'packageManager-field-with-lockfile');

const packageManager = await getPackageManager(testDirectory);

expect(packageManager).toBeDefined();
expect(packageManager!.packageManager).toEqual('pnpm');
});

test('falls back to lock file detection when packageManager executable not found', async () => {
(which_mock.sync as jest.Mock).mockImplementation((executable: string) => {
// pnpm not in PATH, but yarn is
if (executable === 'pnpm') return undefined;
if (executable === 'yarn') return '/path/to/yarn';
return undefined;
});

// Parent directory has packageManager: pnpm but pnpm not in PATH
// Subdirectory has yarn.lock (no packageManager field to avoid corepack blocking yarn)
// yarn is in PATH, so should fall back to yarn via lock file
const testDirectory = path.join(baseDirectory, 'packageManager-hierarchy-fallback', 'subdir');

const packageManager = await getPackageManager(testDirectory);

expect(packageManager).toBeDefined();
expect(packageManager!.packageManager).toEqual('yarn');
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "packageManager-field-with-lockfile-test",
"version": "1.0.0",
"description": "Test package with packageManager field that differs from lock file",
"packageManager": "pnpm@10.17.0"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "packageManager-hierarchy-fallback-test",
"version": "1.0.0",
"description": "Root package with packageManager field for fallback test",
"packageManager": "pnpm@10.17.0"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "packageManager-hierarchy-fallback-subdir-test",
"version": "1.0.0",
"description": "Subdirectory with yarn.lock but no packageManager field"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
# yarn lockfile v1
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "packageManager-hierarchy-test",
"version": "1.0.0",
"description": "Root package with packageManager field for hierarchy test",
"packageManager": "pnpm@10.17.0"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "packageManager-hierarchy-subdir-test",
"version": "1.0.0",
"description": "Subdirectory package without packageManager field"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "packageManager-npm-field-test",
"version": "1.0.0",
"description": "Test package with packageManager field for npm",
"packageManager": "npm@10.0.0"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "packageManager-pnpm-field-test",
"version": "1.0.0",
"description": "Test package with packageManager field for pnpm",
"packageManager": "pnpm@10.17.0"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "packageManager-yarn-field-test",
"version": "1.0.0",
"description": "Test package with packageManager field for yarn",
"packageManager": "yarn@4.0.0"
}
54 changes: 49 additions & 5 deletions packages/amplify-cli-core/src/utils/packageManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,14 +112,48 @@ export const getPackageManagerByType = (packageManagerType: PackageManagerType):
return packageManagers[packageManagerType];
};

/**
* Walk up the directory tree to find a package.json with a packageManager field.
* This mimics corepack's behavior for detecting the package manager in monorepos.
* The packageManager field format is like "pnpm@10.17.0+sha512..." or "yarn@4.0.0"
*
* @param startPath - The directory to start searching from
* @returns The detected package manager type, or null if not found
*/
const findPackageManagerFieldInHierarchy = (startPath: string): PackageManagerType | null => {
let currentPath = path.resolve(startPath);
const root = path.parse(currentPath).root;

while (currentPath !== root) {
const pkgJsonPath = path.join(currentPath, packageJson);
if (fs.existsSync(pkgJsonPath)) {
try {
const pkgJsonContent = fs.readJsonSync(pkgJsonPath);
if (pkgJsonContent.packageManager && typeof pkgJsonContent.packageManager === 'string') {
// packageManager field format: "pnpm@10.28.2+sha512..." or "yarn@4.0.0"
const match = pkgJsonContent.packageManager.match(/^(npm|yarn|pnpm)@/);
if (match) {
return match[1] as PackageManagerType;
}
}
} catch {
// Ignore JSON parse errors and continue searching
}
}
currentPath = path.dirname(currentPath);
}
return null;
};

/**
* Detect the package manager in the passed in directory or process.cwd, with a preference to yarn over npm
* 1. Check if a package.json file present in the directory as it is mandatory, if not return null
* 2. Check if yarn.lock is present and yarn is present and .yarnrc.yml is present on the system for yarn2
* 3. Check if yarn.lock is present and yarn is present on the system
* 4. Check if package-lock.json is present
* 5. Check if yarn present on the system
* 6. Fallback to npm
* 2. Check for packageManager field in package.json hierarchy (corepack convention for monorepos)
* 3. Check if pnpm-lock.yaml is present and pnpm is present on the system
* 4. Check if yarn.lock is present and yarn is present on the system
* 5. Check if package-lock.json is present
* 6. Check if yarn present on the system
* 7. Fallback to npm
@returns {PackageManager | null} instance for the package manager that was detected or null if not found.
*/
export const getPackageManager = async (rootPath?: string): Promise<PackageManager | null> => {
Expand All @@ -132,6 +166,16 @@ export const getPackageManager = async (rootPath?: string): Promise<PackageManag
return null;
}

// Check for packageManager field in package.json hierarchy (corepack convention)
// This handles monorepos where the packageManager field is in the root package.json
const packageManagerFromField = findPackageManagerFieldInHierarchy(effectiveRootPath);
if (packageManagerFromField && checkExecutable(packageManagers[packageManagerFromField].executable)) {
if (packageManagerFromField === 'yarn') {
return getYarnPackageManager(rootPath);
}
return packageManagers[packageManagerFromField];
}

// checks for pnpm
tempFilePath = path.join(effectiveRootPath, packageManagers.pnpm.lockFile);
if (fs.existsSync(tempFilePath) && checkExecutable(packageManagers.pnpm.executable)) {
Expand Down