Skip to content
Merged
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
8 changes: 8 additions & 0 deletions .changeset/multiple-lockfiles-detection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"varlock": patch
---

Fix package manager detection to handle multiple lockfiles gracefully. When multiple lockfiles are found (e.g., both package-lock.json and bun.lockb), the detection now:
1. First tries env var based detection (npm_config_user_agent) to respect the currently active package manager
2. If that fails, returns the first detected package manager as a fallback
3. No longer throws an error, preventing CLI crashes in monorepos or when switching package managers
22 changes: 16 additions & 6 deletions packages/varlock/src/cli/helpers/js-package-manager-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export function detectJsPackageManager(opts?: {
}) {
debug('Detecting js package manager');
let cwd = opts?.cwd || process.cwd();
let multipleLockfilesDetected: Array<JsPackageManager> | undefined;
do {
debug(`> scanning ${cwd}`);
let pm: JsPackageManager;
Expand All @@ -81,18 +82,19 @@ export function detectJsPackageManager(opts?: {
);

if (pathExistsSync(lockFilePath)) {
// if we find 2 lockfiles at the same level, we throw an error
// if we find 2 lockfiles at the same level, store them and continue
// this can happen in monorepos or when switching package managers
if (detectedPm) {
throw new CliExitError('Found multiple js package manager lockfiles', {
details: `${JS_PACKAGE_MANAGERS[pm].lockfile} and ${JS_PACKAGE_MANAGERS[detectedPm].lockfile}`,
forceExit: true,
});
debug(`> found multiple lockfiles: ${JS_PACKAGE_MANAGERS[pm].lockfile} and ${JS_PACKAGE_MANAGERS[detectedPm].lockfile}`);
multipleLockfilesDetected = [detectedPm, pm];
break;
}
debug(`> found ${JS_PACKAGE_MANAGERS[pm].lockfile}`);
detectedPm = pm;
}
}
if (detectedPm) return JS_PACKAGE_MANAGERS[detectedPm];
if (detectedPm && !multipleLockfilesDetected) return JS_PACKAGE_MANAGERS[detectedPm];
if (multipleLockfilesDetected) break;

// will break when we reach the root
const parentDir = path.dirname(cwd);
Expand Down Expand Up @@ -122,6 +124,14 @@ export function detectJsPackageManager(opts?: {
}
}

// if we found multiple lockfiles and env var detection failed, return the first detected one
// we choose the first one because the order is deterministic (based on the order in JS_PACKAGE_MANAGERS)
// and this provides a reasonable fallback when we can't determine the active package manager
if (multipleLockfilesDetected) {
debug(`> using ${multipleLockfilesDetected[0]} from multiple detected lockfiles`);
return JS_PACKAGE_MANAGERS[multipleLockfilesDetected[0]];
}

if (opts?.exitIfNotFound) {
// show some hopefully useful error messaging if we hit the root folder without finding anything
throw new CliExitError('Unable to find detect your JavaScript package manager!', {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import {
describe, test, expect, beforeEach, afterEach,
} from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { detectJsPackageManager } from '../js-package-manager-utils';

describe('detectJsPackageManager', () => {
let tempDir: string;

beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'varlock-test-'));
});

afterEach(() => {
if (tempDir && fs.existsSync(tempDir)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});

test('detects npm from package-lock.json', () => {
const lockFilePath = path.join(tempDir, 'package-lock.json');
fs.writeFileSync(lockFilePath, '{}');

const result = detectJsPackageManager({ cwd: tempDir });
expect(result).toBeDefined();
expect(result?.name).toBe('npm');
});

test('detects pnpm from pnpm-lock.yaml', () => {
const lockFilePath = path.join(tempDir, 'pnpm-lock.yaml');
fs.writeFileSync(lockFilePath, '');

const result = detectJsPackageManager({ cwd: tempDir });
expect(result).toBeDefined();
expect(result?.name).toBe('pnpm');
});

test('detects yarn from yarn.lock', () => {
const lockFilePath = path.join(tempDir, 'yarn.lock');
fs.writeFileSync(lockFilePath, '');

const result = detectJsPackageManager({ cwd: tempDir });
expect(result).toBeDefined();
expect(result?.name).toBe('yarn');
});

test('detects bun from bun.lockb', () => {
const lockFilePath = path.join(tempDir, 'bun.lockb');
fs.writeFileSync(lockFilePath, '');

const result = detectJsPackageManager({ cwd: tempDir });
expect(result).toBeDefined();
expect(result?.name).toBe('bun');
});

test('returns one of the detected package managers when multiple lockfiles are present', () => {
const npmLockPath = path.join(tempDir, 'package-lock.json');
const bunLockPath = path.join(tempDir, 'bun.lockb');
fs.writeFileSync(npmLockPath, '{}');
fs.writeFileSync(bunLockPath, '');

const result = detectJsPackageManager({ cwd: tempDir });
expect(result).toBeDefined();
// If running via pnpm (npm_config_user_agent is set), it will detect pnpm from env var
// Otherwise, it should return one of the detected package managers (npm or bun)
// Both behaviors are correct - env var detection takes precedence
expect(result?.name).toBeTruthy();
});

test('returns one of the detected package managers (pnpm + yarn)', () => {
const pnpmLockPath = path.join(tempDir, 'pnpm-lock.yaml');
const yarnLockPath = path.join(tempDir, 'yarn.lock');
fs.writeFileSync(pnpmLockPath, '');
fs.writeFileSync(yarnLockPath, '');

const result = detectJsPackageManager({ cwd: tempDir });
expect(result).toBeDefined();
// If running via pnpm (npm_config_user_agent is set), it will detect pnpm from env var
// Otherwise, it should return one of the detected package managers (pnpm or yarn)
expect(result?.name).toBeTruthy();
});
});