---
title: "Handle Package Manager Variants"
description: "Not every repo uses pnpm. In this lesson, we detect the lockfile inside the cloned repo, pick `pnpm`/`npm`/`yarn` accordingly, and stop hardcoding the tool name."
canonical_url: "https://vercel.com/academy/vercel-sandbox/handle-package-manager-variants"
md_url: "https://vercel.com/academy/vercel-sandbox/handle-package-manager-variants.md"
docset_id: "vercel-academy"
doc_version: "1.0"
last_updated: "2026-10-30T14:16:22.856Z"
content_type: "lesson"
course: "vercel-sandbox"
course_title: "Vercel Sandbox"
prerequisites:  []
---

<agent-instructions>
Vercel Academy — structured learning, not reference docs.
Lessons are sequenced.
Adapt commands to the human's actual environment (OS, package manager, shell, editor) — detect from project context or ask, don't assume.
The lesson shows one path; if the human's project diverges, adapt concepts to their setup.
Preserve the learning goal over literal steps.
Quizzes are pedagogical — engage, don't spoil.
Quiz answers are included for your reference.
</agent-instructions>

# Handle Package Manager Variants

# 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:

```ts
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',
        // The default Sandbox image has no yarn binary, so Corepack runs it
        install: { cmd: 'corepack', args: ['yarn', 'install'], cwd: repoDir },
        test: { cmd: 'corepack', args: ['yarn', '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
    }));
}
```

Notice that yarn repos don't call `yarn` directly. The default Sandbox image ships Node.js with `npm`, `pnpm`, and Corepack, but no `yarn`. Running `corepack yarn install` lets Corepack fetch the yarn version the repo asks for in `package.json#packageManager` (or Corepack's default when it doesn't ask), then run it. The first yarn run takes a little longer while Corepack downloads it. That's expected.

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:

```ts
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({
    name: `repo-review-${Date.now()}`,
    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.

\*\*Warning: 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.

\*\*Warning: Troubleshooting: yarn: command not found\*\*

If a yarn repo fails with `yarn: command not found`, the command is calling `yarn` directly. Switch it to `cmd: 'corepack'` with `yarn` as the first argument.

\*\*Note: 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`:

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

Expected output:

```txt
Reviewing https://github.com/<...>...
Sandbox: repo-review-1792017569082
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

```bash
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` (run through `corepack`) 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

```ts title="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',
        // The default Sandbox image has no yarn binary, so Corepack runs it
        install: { cmd: 'corepack', args: ['yarn', 'install'], cwd: repoDir },
        test: { cmd: 'corepack', args: ['yarn', '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 }
  };
}
```


---

[Full course index](/academy/llms.txt) · [Sitemap](/academy/sitemap.md)
