Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
5 changes: 5 additions & 0 deletions .changeset/floppy-brooms-cheat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"hardhat": patch
---

Fix package manager detection
4 changes: 2 additions & 2 deletions v-next/hardhat/src/internal/cli/init/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,7 +327,7 @@ export async function validatePackageJson(
await writeJsonFile(absolutePathToPackageJson, packageJson);
}

const packageManager = await getPackageManager(workspace);
const packageManager = getPackageManager();

// We know this works with npm, pnpm, but not with yarn. If, so we use
// pnpm or npm exclusively.
Expand Down Expand Up @@ -491,7 +491,7 @@ export async function installProjectDependencies(
pathToWorkspacePackageJson,
);

const packageManager = await getPackageManager(workspace);
const packageManager = getPackageManager();

// Find the template dev dependencies that are not already installed
const templateDependencies = template.packageJson.devDependencies ?? {};
Expand Down
124 changes: 103 additions & 21 deletions v-next/hardhat/src/internal/cli/init/package-manager.ts
Original file line number Diff line number Diff line change
@@ -1,35 +1,98 @@
import { execSync } from "node:child_process";
import path from "node:path";

import { exists } from "@nomicfoundation/hardhat-utils/fs";
import semver from "semver";

type PackageManager = "npm" | "yarn" | "pnpm";
type PackageManager = "npm" | "yarn" | "pnpm" | "bun" | "deno";

/**
* getPackageManager returns the name of the package manager used in the workspace.
* It determines this by checking the presence of package manager specific lock files.
* getPackageManager returns the name of the package manager used to run Hardhat
*
* @param workspace The path to the workspace where the package manager should be checked.
* @returns The name of the package manager used in the workspace.
* This logic is based on the env variable `npm_config_user_agent`, which is set
* by all major package manager, both when running a package that has been
* installed, and when it hasn't.
*
* Here's how to reproduce it, with the value of the env var:
*
* npm:
*
* uninstalled: npx -y print-environment
* "npm/11.6.1 node/v24.10.0 linux arm64 workspaces/false"
*
* installed: npm init -y && npm i print-environment && npx print-environment
* "npm/11.6.1 node/v24.10.0 linux arm64 workspaces/false"
*
*
* pnpm:
*
* uninstalled: pnpm dlx print-environment
* "pnpm/10.18.3 npm/? node/v24.10.0 linux arm64"
*
* installed: pnpm init && pnpm add print-environment && pnpm print-environment
* "pnpm/10.18.3 npm/? node/v24.10.0 linux arm64"
*
*
* yarn classic:
* uninstalled: unsupported
*
* installed: yarn init -y && yarn add print-environment && yarn print-environment
* "yarn/1.22.22 npm/? node/v24.10.0 linux arm64"
*
* yarn berry:
*
* uninstalled: yarn set version berry && yarn dlx print-environment
* "yarn/4.10.3 npm/? node/v24.10.0 linux arm64"
*
* installed: yarn set version berry && yarn add print-environment && yarn print-environment
* "yarn/4.10.3 npm/? node/v24.10.0 linux arm64"
*
* bun:
*
* uninstalled: bunx print-environment
* "bun/1.3.1 npm/? node/v24.3.0 linux arm64"
*
* installed: bun init -y && bun add print-environment && bun print-environment
* "bun/1.3.1 npm/? node/v24.3.0 linux arm64"
*
* deno:
*
* uninstall: deno run -A npm:print-environment
* "deno/2.5.6 npm/? deno/2.5.6 linux aarch64"
*
* installed: deno init && deno add npm:print-environment && deno --allow-env print-environment
* "deno/2.5.6 npm/? deno/2.5.6 linux aarch64"
*
* @returns The name of the package manager used to run hardhat.
*/
export async function getPackageManager(
workspace: string,
): Promise<PackageManager> {
const pathToYarnLock = path.join(workspace, "yarn.lock");
const pathToPnpmLock = path.join(workspace, "pnpm-lock.yaml");
export function getPackageManager(): PackageManager {
const DEFAULT = "npm";

const invokedFromPnpm = (process.env.npm_config_user_agent ?? "").includes(
"pnpm",
);
const userAgent = process.env.npm_config_user_agent;

if (await exists(pathToYarnLock)) {
return "yarn";
if (userAgent === undefined) {
return DEFAULT;
}
if ((await exists(pathToPnpmLock)) || invokedFromPnpm) {
return "pnpm";

const firstSlashIndex = userAgent.indexOf("/");
if (firstSlashIndex === -1) {
return DEFAULT;
}

const packageManager = userAgent.substring(0, firstSlashIndex);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do the package managers provide any guarantees on the message they use?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above


switch (packageManager) {
case "npm":
return "npm";
case "yarn":
return "yarn";
case "pnpm":
return "pnpm";
case "bun":
return "bun";
case "deno":
return "deno";
default:
return DEFAULT;
}
return "npm";
}

/**
Expand All @@ -49,11 +112,21 @@ export function getDevDependenciesInstallationCommand(
npm: ["npm", "install", "--save-dev"],
yarn: ["yarn", "add", "--dev"],
pnpm: ["pnpm", "add", "--save-dev"],
deno: ["deno", "add"],
bun: ["bun", "add", "--dev"],
};
const command = packageManagerToCommand[packageManager];
// We quote all the dependency identifiers so that they can be run on a shell
// without semver symbols interfering with the command
command.push(...dependencies.map((d) => `"${d}"`));
command.push(
...dependencies.map((d) => {
if (packageManager === "deno") {
return `"npm:${d}"`;
}

return `"${d}"`;
}),
);
return command;
}

Expand Down Expand Up @@ -118,6 +191,15 @@ export async function installsPeerDependenciesByDefault(
}
}
return false;
case "bun":
// Bun has installed peer dependencies for over 2 years, so that's fine
// https://github.com/oven-sh/bun/releases/tag/bun-v1.0.5
// This can be disabled, and there's no easy way to check that, so we
// assume true for now
return true;
case "deno":
// Deno doesn't autoinstall peers
return false;
}
}

Expand Down
109 changes: 87 additions & 22 deletions v-next/hardhat/test/internal/cli/init/package-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import assert from "node:assert/strict";
import { afterEach, beforeEach, describe, it } from "node:test";

import { useTmpDir } from "@nomicfoundation/hardhat-test-utils";
import { writeUtf8File } from "@nomicfoundation/hardhat-utils/fs";

import {
getDevDependenciesInstallationCommand,
Expand All @@ -16,35 +15,69 @@ describe("getPackageManager", () => {
let originalUserAgent: string | undefined;
beforeEach(() => {
originalUserAgent = process.env.npm_config_user_agent;
process.env.npm_config_user_agent = "npm"; // assume we are running npm by default
});

afterEach(() => {
process.env.npm_config_user_agent = originalUserAgent;
});

it("should return pnpm if pnpm-lock.yaml exists", async () => {
await writeUtf8File("pnpm-lock.yaml", "");
assert.equal(await getPackageManager(process.cwd()), "pnpm");
});
const fixtureValues = [
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have end-to-end tests to alert us in case any of the package managers change this behaviour?
They could change the the variable, format of the message or stop reporting.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, but I think the chances of that changing are remote, as they would break the entire ecosystem

{
agentString: "npm/11.6.1 node/v24.10.0 linux arm64 workspaces/false",
expected: "npm",
},
{
agentString: "npm/11.6.1 node/v24.10.0 linux arm64 workspaces/false",
expected: "npm",
},
{
agentString: "pnpm/10.18.3 npm/? node/v24.10.0 linux arm64",
expected: "pnpm",
},
{
agentString: "pnpm/10.18.3 npm/? node/v24.10.0 linux arm64",
expected: "pnpm",
},
{
agentString: "yarn/1.22.22 npm/? node/v24.10.0 linux arm64",
expected: "yarn",
},
{
agentString: "yarn/4.10.3 npm/? node/v24.10.0 linux arm64",
expected: "yarn",
},
{
agentString: "yarn/4.10.3 npm/? node/v24.10.0 linux arm64",
expected: "yarn",
},
{
agentString: "bun/1.3.1 npm/? node/v24.3.0 linux arm64",
expected: "bun",
},
{
agentString: "bun/1.3.1 npm/? node/v24.3.0 linux arm64",
expected: "bun",
},
{
Comment on lines 38 to 41
Copy link

Copilot AI Nov 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate test fixture entries detected. Lines 53-56 and 57-60 contain identical agent strings and expected values. Consider removing one of these duplicate entries.

Suggested change
agentString: "bun/1.3.1 npm/? node/v24.3.0 linux arm64",
expected: "bun",
},
{

Copilot uses AI. Check for mistakes.
agentString: "deno/2.5.6 npm/? deno/2.5.6 linux aarch64",
expected: "deno",
},
{
agentString: "deno/2.5.6 npm/? deno/2.5.6 linux aarch64",
expected: "deno",
},
];

it("should return pnpm if invoked from pnpx", async () => {
assert.equal(await getPackageManager(process.cwd()), "npm");
process.env.npm_config_user_agent =
"pnpm/10.4.1 npm/? node/v23.2.0 linux x64";
assert.equal(await getPackageManager(process.cwd()), "pnpm");
});
it("Should work for all the fixture values", () => {
for (const fixture of fixtureValues) {
process.env.npm_config_user_agent = fixture.agentString;

it("should return npm if package-lock.json exists", async () => {
await writeUtf8File("package-lock.json", "");
assert.equal(await getPackageManager(process.cwd()), "npm");
});
it("should return yarn if yarn.lock exists", async () => {
await writeUtf8File("yarn.lock", "");
assert.equal(await getPackageManager(process.cwd()), "yarn");
});
it("should return npm if no lock file exists", async () => {
assert.equal(await getPackageManager(process.cwd()), "npm");
assert.equal(
getPackageManager(),
fixture.expected,
"Incorrect package manager for " + fixture.expected,
);
}
});
});

Expand Down Expand Up @@ -150,19 +183,51 @@ describe("installsPeerDependenciesByDefault", () => {
assert.equal(actual, false);
});
});

describe("for bun", () => {
it("should always return true", async () => {
const actual = await installsPeerDependenciesByDefault(
process.cwd(),
"bun",
);
assert.equal(actual, true);
});
});

describe("for deno", () => {
it("should always return false", async () => {
const actual = await installsPeerDependenciesByDefault(
process.cwd(),
"deno",
);
assert.equal(actual, false);
});
});
});

describe("getDevDependenciesInstallationCommand", () => {
it("should return the correct command for pnpm", async () => {
const command = getDevDependenciesInstallationCommand("pnpm", ["a", "b"]);
assert.equal(command.join(" "), 'pnpm add --save-dev "a" "b"');
});

it("should return the correct command for npm", async () => {
const command = getDevDependenciesInstallationCommand("npm", ["a", "b"]);
assert.equal(command.join(" "), 'npm install --save-dev "a" "b"');
});

it("should return the correct command for yarn", async () => {
const command = getDevDependenciesInstallationCommand("yarn", ["a", "b"]);
assert.equal(command.join(" "), 'yarn add --dev "a" "b"');
});

it("should return the correct command for bun", async () => {
const command = getDevDependenciesInstallationCommand("bun", ["a", "b"]);
assert.equal(command.join(" "), 'bun add --dev "a" "b"');
});

it("should return the correct command for deno", async () => {
const command = getDevDependenciesInstallationCommand("deno", ["a", "b"]);
assert.equal(command.join(" "), 'deno add "npm:a" "npm:b"');
});
});
Loading