Vercel Logo

Detect the Package Manager

Hardcoding pnpm test worked for exactly one kind of repo.

Running pnpm install in a repo with package-lock.json can create a new pnpm lockfile and resolve dependencies differently from the project's intended tool. We need to inspect the cloned repo and pick the matching package manager.

Outcome

Add a detectPackageManager(sandbox) helper to src/test-runner.ts that reads which lockfile the repo has and returns the matching package manager. Update the lifecycle to use it.

Fast Track

  1. In src/test-runner.ts, add detectPackageManager(sandbox) that checks for pnpm-lock.yaml, yarn.lock, or package-lock.json.
  2. Return a { install, test } command pair for each.
  3. In src/sandbox-lifecycle.ts, call the helper and use its commands.

Hands-on exercise

Open src/test-runner.ts and add the detection helper:

import type { Sandbox } from '@vercel/sandbox';
 
export type TestFinding = {
  severity: 'medium' | 'high';
  category: 'test-failure';
  summary: string;
  details: string;
};
 
export type PackageManagerCommands = {
  name: 'pnpm' | 'npm' | 'yarn';
  install: { cmd: string; args: string[]; cwd: string };
  test: { cmd: string; args: string[]; cwd: string };
};
 
const FAILURE_MARKERS = ['FAIL ', '✕ ', '× '];
 
export async function detectPackageManager(
  sandbox: Sandbox,
  repoDir = 'repo'
): Promise<PackageManagerCommands> {
  const checks: Array<{ file: string; commands: PackageManagerCommands }> = [
    {
      file: 'pnpm-lock.yaml',
      commands: {
        name: 'pnpm',
        install: { cmd: 'pnpm', args: ['install'], cwd: repoDir },
        test: { cmd: 'pnpm', args: ['test'], cwd: repoDir }
      }
    },
    {
      file: 'yarn.lock',
      commands: {
        name: 'yarn',
        install: { cmd: 'yarn', args: ['install'], cwd: repoDir },
        test: { cmd: 'yarn', args: ['test'], cwd: repoDir }
      }
    },
    {
      file: 'package-lock.json',
      commands: {
        name: 'npm',
        install: { cmd: 'npm', args: ['install'], cwd: repoDir },
        test: { cmd: 'npm', args: ['test'], cwd: repoDir }
      }
    }
  ];
 
  for (const { file, commands } of checks) {
    if (await sandbox.fs.exists(`${repoDir}/${file}`)) {
      return commands;
    }
  }
 
  // No lockfile at all: default to npm install as the last-resort fallback
  return {
    name: 'npm',
    install: { cmd: 'npm', args: ['install'], cwd: repoDir },
    test: { cmd: 'npm', args: ['test'], cwd: repoDir }
  };
}
 
export function parseTestFailures(output: string): TestFinding[] {
  const lines = output.split('\n');
 
  return lines
    .map((line) => line.trim())
    .filter((line) => FAILURE_MARKERS.some((marker) => line.startsWith(marker)))
    .map((line) => ({
      severity: 'high' as const,
      category: 'test-failure' as const,
      summary: 'Automated test failure',
      details: line
    }));
}

The detection runs through the lockfile candidates in order and stops at the first match. If none of them match, we fall back to npm install without a lockfile, knowing the result may not be reproducible. That fallback exists so the tool doesn't crash on weird repos; it's not a real recommendation.

Now update src/sandbox-lifecycle.ts to use it:

import { Sandbox } from '@vercel/sandbox';
import { detectPackageManager } from './test-runner';
 
const INTERESTING_PATHS = [
  'repo/package.json',
  'repo/src/index.ts',
  'repo/src/app.ts',
  'repo/lib/auth.ts'
];
 
export type TestResult = {
  exitCode: number;
  stdout: string;
  stderr: string;
  packageManager: string;
};
 
export type LifecycleResult = {
  sandboxName: string;
  cloneExitCode: number;
  files: Array<{ path: string; content: string }>;
  testResult: TestResult;
};
 
export async function runSandboxLifecycle(repoUrl: string): Promise<LifecycleResult> {
  const sandbox = await Sandbox.create({ persistent: false, timeout: 10 * 60 * 1000 });
 
  try {
    const clone = await sandbox.runCommand('git', ['clone', '--depth', '1', repoUrl, 'repo']);
    if (clone.exitCode !== 0) {
      throw new Error(`Clone failed: ${await clone.stderr()}`);
    }
 
    const files: Array<{ path: string; content: string }> = [];
    for (const fullPath of INTERESTING_PATHS) {
      const content = await sandbox.readFileToBuffer({ path: fullPath });
      if (content) {
        files.push({
          path: fullPath.replace(/^repo\//, ''),
          content: content.toString('utf8')
        });
      }
    }
 
    const pm = await detectPackageManager(sandbox);
    const install = await sandbox.runCommand(pm.install);
    if (install.exitCode !== 0) {
      throw new Error(`Install failed: ${await install.stderr()}`);
    }
    const test = await sandbox.runCommand(pm.test);
 
    return {
      sandboxName: sandbox.name,
      cloneExitCode: clone.exitCode,
      files,
      testResult: {
        exitCode: test.exitCode,
        stdout: await test.stdout(),
        stderr: await test.stderr(),
        packageManager: pm.name
      }
    };
  } finally {
    await sandbox.stop();
  }
}

The lifecycle no longer cares which package manager the repo uses. It asks, gets back a command pair, and runs them.

Troubleshooting: detection returns wrong manager

Some repos have multiple lockfiles (pnpm-lock.yaml and package-lock.json). The detection picks the first one in the priority order, which may not match what the repo's contributors actually use. If you hit this, swap the order in the checks array.

Troubleshooting: stricter installs in production

We're using plain install commands for simplicity. Production code should inspect package.json#packageManager, enable the matching Corepack version, verify the binary exists, and choose its strict mode. For example, use pnpm install --frozen-lockfile, npm ci, Yarn Berry's --immutable, or Yarn 1's --frozen-lockfile as appropriate.

Try It

Run against a repo with package-lock.json:

pnpm review https://github.com/<some-npm-repo>

Expected output:

Reviewing https://github.com/<...>...
Sandbox: sbx_7N2k4A...
Collected 2 file(s) for analysis.
Overall risk: low
Findings: 2
  ...

The tests should still run, even though the repo isn't a pnpm project. If you add a temporary log of lifecycle.testResult.packageManager, you'll see "npm" instead of "pnpm".

Commit

git add src/test-runner.ts src/sandbox-lifecycle.ts
git commit -m "feat(testing): detect package manager from lockfile"

Done-When

  • detectPackageManager returns pnpm for repos with pnpm-lock.yaml
  • Returns yarn for repos with yarn.lock
  • Returns npm for repos with package-lock.json
  • Falls back to npm install (loose) when no lockfile is present
  • Lifecycle uses the returned commands instead of hardcoded pnpm

Solution

src/test-runner.ts (relevant additions)
import type { Sandbox } from '@vercel/sandbox';
 
export type PackageManagerCommands = {
  name: 'pnpm' | 'npm' | 'yarn';
  install: { cmd: string; args: string[]; cwd: string };
  test: { cmd: string; args: string[]; cwd: string };
};
 
export async function detectPackageManager(
  sandbox: Sandbox,
  repoDir = 'repo'
): Promise<PackageManagerCommands> {
  const checks: Array<{ file: string; commands: PackageManagerCommands }> = [
    {
      file: 'pnpm-lock.yaml',
      commands: {
        name: 'pnpm',
        install: { cmd: 'pnpm', args: ['install'], cwd: repoDir },
        test: { cmd: 'pnpm', args: ['test'], cwd: repoDir }
      }
    },
    {
      file: 'yarn.lock',
      commands: {
        name: 'yarn',
        install: { cmd: 'yarn', args: ['install'], cwd: repoDir },
        test: { cmd: 'yarn', args: ['test'], cwd: repoDir }
      }
    },
    {
      file: 'package-lock.json',
      commands: {
        name: 'npm',
        install: { cmd: 'npm', args: ['install'], cwd: repoDir },
        test: { cmd: 'npm', args: ['test'], cwd: repoDir }
      }
    }
  ];
 
  for (const { file, commands } of checks) {
    if (await sandbox.fs.exists(`${repoDir}/${file}`)) {
      return commands;
    }
  }
 
  return {
    name: 'npm',
    install: { cmd: 'npm', args: ['install'], cwd: repoDir },
    test: { cmd: 'npm', args: ['test'], cwd: repoDir }
  };
}

Was this helpful?

supported.