From 1a2c496ffc6620aae0c133ec32c66ebc52142b82 Mon Sep 17 00:00:00 2001 From: gmegidish Date: Fri, 18 Sep 2026 14:58:11 +0200 Subject: [PATCH 1/3] feat: install with the project's package manager and print its commands - detect npm/pnpm/yarn/yarn classic/bun from the lockfile, then packageManager, then npm_config_user_agent; a pnpm project no longer gets a package-lock.json - workspace roots get -w (pnpm) / -W (yarn classic) - success message and the retry hint use that package manager (pnpm exec / yarn / bunx / npx) and mention doctor and --list - README: mention Bun and lockfile detection, link Mobile Next Cloud --- README.md | 4 +- src/index.ts | 23 ++++--- src/package-manager.ts | 130 +++++++++++++++++++++++++++++++++++ src/project.ts | 14 ++-- test/package-manager.test.ts | 83 ++++++++++++++++++++++ test/project.test.ts | 4 +- 6 files changed, 240 insertions(+), 18 deletions(-) create mode 100644 src/package-manager.ts create mode 100644 test/package-manager.test.ts diff --git a/README.md b/README.md index 2b931b7..d65ec9a 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Scaffold a [Mobilewright](https://mobilewright.dev) test project in seconds. npm init mobilewright@latest ``` -Also works with Yarn (`yarn create mobilewright`) and pnpm (`pnpm create mobilewright`). +Also works with Yarn (`yarn create mobilewright`), pnpm (`pnpm create mobilewright`) and Bun (`bun create mobilewright`). The project's own package manager is detected from its lockfile, so dependencies are installed with the tool you already use. The CLI walks you through setup and creates a ready-to-run project: @@ -40,7 +40,7 @@ If something's missing, `npx mobilewright doctor` tells you exactly what to fix. ## Next steps - [Mobilewright docs](https://mobilewright.dev/docs) — API reference and guides -- [mobile-use.com](https://mobile-use.com) — Run tests on real devices in the cloud +- [Mobile Next Cloud](https://mobilenext.ai/cloud?utm_source=github&utm_medium=readme&utm_campaign=create-mobilewright&utm_content=next-steps) — Run tests on real devices in the cloud ## License diff --git a/src/index.ts b/src/index.ts index d058e8b..287f505 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,6 +6,7 @@ import path from "path"; import prompts from "prompts"; import { execSync } from "child_process"; import { detectApps, DetectedApp, Platform } from "./detect"; +import { createProjectCommand, detectPackageManager, PackageManager, runCommand } from "./package-manager"; import { chooseDefaultTestDir, createConfigContent, @@ -116,19 +117,19 @@ function writeJson(filePath: string, value: unknown): void { // same order as create-playwright: package.json and install first, so a failed // install leaves no half-scaffolded project behind -function installDependencies(targetDir: string, language: Language): void { +function installDependencies(targetDir: string, language: Language, packageManager: PackageManager): void { const pkgPath = path.join(targetDir, "package.json"); const existing = readPackageJson(pkgPath); - // a blank package.json would make npm fail with EJSONPARSE + // a blank package.json would make the package manager fail on a parse error if (existing === undefined || Object.keys(existing).length === 0) writeJson(pkgPath, createNewPackageJson(targetDir)); console.log("\nInstalling dependencies...\n"); - for (const command of installCommands(planInstall(existing ?? {}, language, process.versions.node))) { + for (const command of installCommands(planInstall(existing ?? {}, language, process.versions.node), packageManager, existing ?? {})) { console.log(`${command}\n`); try { execSync(command, { cwd: targetDir, stdio: "inherit" }); } catch { - console.error("\nFailed to install dependencies. No test files were created; fix the error above and run npm init mobilewright@latest again."); + console.error(`\nFailed to install dependencies. No test files were created; fix the error above and run ${createProjectCommand(packageManager)} again.`); process.exit(1); } } @@ -156,12 +157,17 @@ function writeProjectFiles(targetDir: string, answers: Answers): void { writeJson(pkgPath, withTestScript(readPackageJson(pkgPath) ?? {})); } -function printSuccess(runners: TestRunner[], testDir: string): void { +function printSuccess(runners: TestRunner[], testDir: string, packageManager: PackageManager): void { console.log(` Success! Created mobilewright project. From this directory, you can run: - npx mobilewright test + ${runCommand(packageManager, "test")} + Runs your tests. Needs a booted simulator/emulator or a connected device. + ${runCommand(packageManager, "test --list")} + Lists the tests without running them. + ${runCommand(packageManager, "doctor")} + Checks your setup. Visit https://mobilewright.dev for more information.`); @@ -184,12 +190,13 @@ async function main() { const targetDir = process.cwd(); const existingPkg = readPackageJson(path.join(targetDir, "package.json")) ?? {}; const runners = detectOtherTestRunners(targetDir, existingPkg); + const packageManager = detectPackageManager(targetDir, existingPkg.packageManager); const answers = await askQuestions(targetDir, detectApps(targetDir), chooseDefaultTestDir(targetDir, runners)); const validation = validateTestDir(targetDir, answers.testDir); if (validation !== true) throw new UserFacingError(validation); - installDependencies(targetDir, answers.language); + installDependencies(targetDir, answers.language, packageManager); writeProjectFiles(targetDir, answers); const problem = findInstallProblem(targetDir); @@ -198,7 +205,7 @@ async function main() { process.exit(1); } - printSuccess(runners, answers.testDir); + printSuccess(runners, answers.testDir, packageManager); } main().catch((error) => { diff --git a/src/package-manager.ts b/src/package-manager.ts new file mode 100644 index 0000000..e7671f8 --- /dev/null +++ b/src/package-manager.ts @@ -0,0 +1,130 @@ +import fs from "fs"; +import path from "path"; + +export type PackageManager = "npm" | "pnpm" | "yarn" | "yarn-classic" | "bun"; + +type Lockfile = { + file: string; + packageManager: PackageManager; +}; + +const LOCKFILES: Lockfile[] = [ + { file: "pnpm-lock.yaml", packageManager: "pnpm" }, + { file: "bun.lock", packageManager: "bun" }, + { file: "bun.lockb", packageManager: "bun" }, + { file: "yarn.lock", packageManager: "yarn" }, + { file: "package-lock.json", packageManager: "npm" }, +]; + +function isYarnClassic(targetDir: string, version: string | undefined): boolean { + // berry keeps its settings in .yarnrc.yml; yarn 1 has no such file + if (fs.existsSync(path.join(targetDir, ".yarnrc.yml"))) return false; + return version === undefined || version.startsWith("0.") || version.startsWith("1."); +} + +function fromUserAgent(targetDir: string, userAgent: string): PackageManager | undefined { + const match = userAgent.match(/^(npm|pnpm|yarn|bun)\/(\S+)/); + if (!match) return undefined; + const [, name, version] = match; + if (name === "yarn") return isYarnClassic(targetDir, version) ? "yarn-classic" : "yarn"; + return name as PackageManager; +} + +/** + * The lockfile in the project wins over the tool that started us: `npm init mobilewright` + * inside a pnpm project should still install with pnpm. + */ +export function detectPackageManager(targetDir: string, packageManagerField?: string, userAgent = process.env.npm_config_user_agent): PackageManager { + const lockfile = LOCKFILES.find((candidate) => fs.existsSync(path.join(targetDir, candidate.file))); + const declared = packageManagerField?.match(/^(npm|pnpm|yarn|bun)@?(\S+)?/); + const detected = lockfile?.packageManager ?? (declared ? (declared[1] as PackageManager) : undefined); + + if (detected === "yarn") { + return isYarnClassic(targetDir, declared?.[1] === "yarn" ? declared[2] : undefined) ? "yarn-classic" : "yarn"; + } + if (detected) return detected; + return (userAgent ? fromUserAgent(targetDir, userAgent) : undefined) ?? "npm"; +} + +// yarn 1 and pnpm refuse to add a dependency in a workspace root without this flag +function workspaceFlag(packageManager: PackageManager, isWorkspaceRoot: boolean): string { + if (!isWorkspaceRoot) return ""; + if (packageManager === "pnpm") return "-w "; + return packageManager === "yarn-classic" ? "-W " : ""; +} + +export function installDevCommand(packageManager: PackageManager, specs: string[], isWorkspaceRoot = false): string { + const quoted = specs.map((spec) => `"${spec}"`).join(" "); + const workspace = workspaceFlag(packageManager, isWorkspaceRoot); + switch (packageManager) { + case "pnpm": + return `pnpm add --save-dev ${workspace}${quoted}`; + case "yarn": + case "yarn-classic": + return `yarn add --dev ${workspace}${quoted}`; + case "bun": + return `bun add --development ${quoted}`; + case "npm": + // --include=dev: otherwise NODE_ENV=production silently skips devDependencies + return `npm install --save-dev --include=dev ${quoted}`; + } +} + +export function installProdCommand(packageManager: PackageManager, specs: string[], isWorkspaceRoot = false): string { + const quoted = specs.map((spec) => `"${spec}"`).join(" "); + const workspace = workspaceFlag(packageManager, isWorkspaceRoot); + switch (packageManager) { + case "pnpm": + return `pnpm add ${workspace}${quoted}`; + case "yarn": + case "yarn-classic": + return `yarn add ${workspace}${quoted}`; + case "bun": + return `bun add ${quoted}`; + case "npm": + return `npm install --save-prod --include=dev ${quoted}`; + } +} + +export function installAllCommand(packageManager: PackageManager): string { + switch (packageManager) { + case "pnpm": + return "pnpm install"; + case "yarn": + case "yarn-classic": + return "yarn install"; + case "bun": + return "bun install"; + case "npm": + return "npm install --include=dev"; + } +} + +/** How the user runs the mobilewright binary that was just installed. */ +export function runCommand(packageManager: PackageManager, args: string): string { + switch (packageManager) { + case "pnpm": + return `pnpm exec mobilewright ${args}`; + case "yarn": + case "yarn-classic": + return `yarn mobilewright ${args}`; + case "bun": + return `bunx mobilewright ${args}`; + case "npm": + return `npx mobilewright ${args}`; + } +} + +export function createProjectCommand(packageManager: PackageManager): string { + switch (packageManager) { + case "pnpm": + return "pnpm create mobilewright"; + case "yarn": + case "yarn-classic": + return "yarn create mobilewright"; + case "bun": + return "bun create mobilewright"; + case "npm": + return "npm init mobilewright@latest"; + } +} diff --git a/src/project.ts b/src/project.ts index 2f08e68..d2803e3 100644 --- a/src/project.ts +++ b/src/project.ts @@ -1,6 +1,7 @@ import fs from "fs"; import path from "path"; import type { Platform } from "./detect"; +import { installAllCommand, installDevCommand, installProdCommand, PackageManager } from "./package-manager"; export type Language = "ts" | "js"; @@ -11,6 +12,8 @@ export type PackageJson = { dependencies?: Record; devDependencies?: Record; jest?: unknown; + workspaces?: unknown; + packageManager?: string; [key: string]: unknown; }; @@ -118,14 +121,13 @@ export function planInstall(pkg: PackageJson, language: Language, nodeVersion: s }; } -export function installCommands({ dependencies, devDependencies }: InstallPlan): string[] { - // --include=dev: otherwise NODE_ENV=production silently skips devDependencies - const quoted = (specs: string[]) => specs.map((spec) => `"${spec}"`).join(" "); +export function installCommands({ dependencies, devDependencies }: InstallPlan, packageManager: PackageManager, pkg: PackageJson = {}): string[] { + const isWorkspaceRoot = pkg.workspaces !== undefined; const commands = [ - ...(devDependencies.length > 0 ? [`npm install --save-dev --include=dev ${quoted(devDependencies)}`] : []), - ...(dependencies.length > 0 ? [`npm install --save-prod --include=dev ${quoted(dependencies)}`] : []), + ...(devDependencies.length > 0 ? [installDevCommand(packageManager, devDependencies, isWorkspaceRoot)] : []), + ...(dependencies.length > 0 ? [installProdCommand(packageManager, dependencies, isWorkspaceRoot)] : []), ]; - return commands.length > 0 ? commands : ["npm install --include=dev"]; + return commands.length > 0 ? commands : [installAllCommand(packageManager)]; } // JSON.stringify gives a valid JS string literal for any user input diff --git a/test/package-manager.test.ts b/test/package-manager.test.ts new file mode 100644 index 0000000..2750a1e --- /dev/null +++ b/test/package-manager.test.ts @@ -0,0 +1,83 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + createProjectCommand, + detectPackageManager, + installAllCommand, + installDevCommand, + installProdCommand, + PackageManager, + runCommand, +} from "../src/package-manager"; +import { createTempDir, writeFile } from "./helpers"; + +const NPM_USER_AGENT = "npm/11.19.0 node/v24.21.0 darwin arm64 workspaces/false"; +const PNPM_USER_AGENT = "pnpm/12.4.2 npm/? node/v24.21.0 darwin arm64"; +const YARN_CLASSIC_USER_AGENT = "yarn/1.22.22 npm/? node/v24.21.0 darwin arm64"; +const YARN_BERRY_USER_AGENT = "yarn/4.18.0 npm/? node/v24.21.0 darwin arm64"; +const BUN_USER_AGENT = "bun/1.3.14 npm/? node/v24.3.0 darwin arm64"; + +function projectWith(files: Record): string { + const dir = createTempDir(); + for (const [name, content] of Object.entries(files)) writeFile(dir, name, content); + return dir; +} + +test("a lockfile decides the package manager, whichever tool started us", () => { + assert.equal(detectPackageManager(projectWith({ "pnpm-lock.yaml": "" }), undefined, NPM_USER_AGENT), "pnpm"); + assert.equal(detectPackageManager(projectWith({ "bun.lockb": "" }), undefined, NPM_USER_AGENT), "bun"); + assert.equal(detectPackageManager(projectWith({ "package-lock.json": "{}" }), undefined, PNPM_USER_AGENT), "npm"); +}); + +test("a yarn.lock without .yarnrc.yml means yarn classic, with it means berry", () => { + assert.equal(detectPackageManager(projectWith({ "yarn.lock": "" })), "yarn-classic"); + assert.equal(detectPackageManager(projectWith({ "yarn.lock": "", ".yarnrc.yml": "nodeLinker: pnp" })), "yarn"); +}); + +test("the packageManager field is used when there is no lockfile", () => { + assert.equal(detectPackageManager(createTempDir(), "pnpm@9.12.0", NPM_USER_AGENT), "pnpm"); + assert.equal(detectPackageManager(createTempDir(), "yarn@4.18.0"), "yarn"); +}); + +test("otherwise the tool that started us decides", () => { + assert.equal(detectPackageManager(createTempDir(), undefined, PNPM_USER_AGENT), "pnpm"); + assert.equal(detectPackageManager(createTempDir(), undefined, YARN_CLASSIC_USER_AGENT), "yarn-classic"); + assert.equal(detectPackageManager(createTempDir(), undefined, YARN_BERRY_USER_AGENT), "yarn"); + assert.equal(detectPackageManager(createTempDir(), undefined, BUN_USER_AGENT), "bun"); + assert.equal(detectPackageManager(createTempDir(), undefined, undefined), "npm"); + assert.equal(detectPackageManager(createTempDir(), undefined, "deno/2.0.0"), "npm"); +}); + +test("each package manager gets its own install commands", () => { + const expected: Record = { + npm: ['npm install --save-dev --include=dev "mobilewright@latest"', "npm install --include=dev"], + pnpm: ['pnpm add --save-dev "mobilewright@latest"', "pnpm install"], + yarn: ['yarn add --dev "mobilewright@latest"', "yarn install"], + "yarn-classic": ['yarn add --dev "mobilewright@latest"', "yarn install"], + bun: ['bun add --development "mobilewright@latest"', "bun install"], + }; + for (const [packageManager, [dev, all]] of Object.entries(expected) as [PackageManager, [string, string]][]) { + assert.equal(installDevCommand(packageManager, ["mobilewright@latest"]), dev); + assert.equal(installAllCommand(packageManager), all); + } +}); + +test("yarn classic and pnpm need a workspace flag in a workspace root", () => { + assert.equal(installDevCommand("yarn-classic", ["mobilewright@latest"], true), 'yarn add --dev -W "mobilewright@latest"'); + assert.equal(installProdCommand("pnpm", ["mobilewright@latest"], true), 'pnpm add -w "mobilewright@latest"'); + assert.equal(installDevCommand("yarn", ["mobilewright@latest"], true), 'yarn add --dev "mobilewright@latest"'); + assert.equal(installDevCommand("npm", ["mobilewright@latest"], true), 'npm install --save-dev --include=dev "mobilewright@latest"'); +}); + +test("the printed run command matches the package manager", () => { + assert.equal(runCommand("npm", "test"), "npx mobilewright test"); + assert.equal(runCommand("pnpm", "test --list"), "pnpm exec mobilewright test --list"); + assert.equal(runCommand("yarn-classic", "doctor"), "yarn mobilewright doctor"); + assert.equal(runCommand("bun", "test"), "bunx mobilewright test"); +}); + +test("the retry hint uses the same package manager", () => { + assert.equal(createProjectCommand("npm"), "npm init mobilewright@latest"); + assert.equal(createProjectCommand("pnpm"), "pnpm create mobilewright"); + assert.equal(createProjectCommand("bun"), "bun create mobilewright"); +}); diff --git a/test/project.test.ts b/test/project.test.ts index dfbd22e..f79c9ac 100644 --- a/test/project.test.ts +++ b/test/project.test.ts @@ -142,14 +142,14 @@ test("dependencies: @types/node uses a published major (there is no @types/node }); test("install: commands quote every spec and never use a pinned mobilewright version", () => { - assert.deepEqual(installCommands({ dependencies: ["mobilewright"], devDependencies: ["@mobilewright/test", "@types/node@^24"] }), [ + assert.deepEqual(installCommands({ dependencies: ["mobilewright"], devDependencies: ["@mobilewright/test", "@types/node@^24"] }, "npm"), [ 'npm install --save-dev --include=dev "@mobilewright/test" "@types/node@^24"', 'npm install --save-prod --include=dev "mobilewright"', ]); }); test("install: nothing to add still runs npm install so existing dependencies are present", () => { - assert.deepEqual(installCommands({ dependencies: [], devDependencies: [] }), ["npm install --include=dev"]); + assert.deepEqual(installCommands({ dependencies: [], devDependencies: [] }, "pnpm"), ["pnpm install"]); }); test("install: the input package.json object is not mutated", () => { From 89d3f1a7b92c2e70b932da8340650cf99d279d01 Mon Sep 17 00:00:00 2001 From: gmegidish Date: Mon, 21 Sep 2026 12:56:33 +0200 Subject: [PATCH 2/3] fix: find the package manager from workspace roots, detect pnpm-workspace.yaml --- src/index.ts | 5 ++-- src/package-manager.ts | 55 ++++++++++++++++++++++++++++++------ src/project.ts | 3 +- test/package-manager.test.ts | 32 +++++++++++++++++++++ 4 files changed, 83 insertions(+), 12 deletions(-) diff --git a/src/index.ts b/src/index.ts index 287f505..2788e64 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,7 +6,7 @@ import path from "path"; import prompts from "prompts"; import { execSync } from "child_process"; import { detectApps, DetectedApp, Platform } from "./detect"; -import { createProjectCommand, detectPackageManager, PackageManager, runCommand } from "./package-manager"; +import { createProjectCommand, detectPackageManager, isWorkspaceRoot, PackageManager, runCommand } from "./package-manager"; import { chooseDefaultTestDir, createConfigContent, @@ -124,7 +124,8 @@ function installDependencies(targetDir: string, language: Language, packageManag if (existing === undefined || Object.keys(existing).length === 0) writeJson(pkgPath, createNewPackageJson(targetDir)); console.log("\nInstalling dependencies...\n"); - for (const command of installCommands(planInstall(existing ?? {}, language, process.versions.node), packageManager, existing ?? {})) { + const plan = planInstall(existing ?? {}, language, process.versions.node); + for (const command of installCommands(plan, packageManager, isWorkspaceRoot(targetDir, existing?.workspaces))) { console.log(`${command}\n`); try { execSync(command, { cwd: targetDir, stdio: "inherit" }); diff --git a/src/package-manager.ts b/src/package-manager.ts index e7671f8..40cb06b 100644 --- a/src/package-manager.ts +++ b/src/package-manager.ts @@ -1,4 +1,5 @@ import fs from "fs"; +import os from "os"; import path from "path"; export type PackageManager = "npm" | "pnpm" | "yarn" | "yarn-classic" | "bun"; @@ -22,6 +23,43 @@ function isYarnClassic(targetDir: string, version: string | undefined): boolean return version === undefined || version.startsWith("0.") || version.startsWith("1."); } +/** + * The project directory and the workspace roots above it. Stops at the repository root and + * never leaves the home directory, so a stray lockfile in $HOME can't decide for a project. + */ +function ancestorDirs(dir: string, homeDir: string): string[] { + const dirs: string[] = []; + for (let current = dir; ; current = path.dirname(current)) { + dirs.push(current); + const parent = path.dirname(current); + if (fs.existsSync(path.join(current, ".git")) || current === homeDir || parent === current || parent === homeDir) { + return dirs; + } + } +} + +function readPackageManagerField(dir: string): string | undefined { + try { + return JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf-8")).packageManager; + } catch { + return undefined; + } +} + +function resolveYarn(dir: string, version: string | undefined): PackageManager { + return isYarnClassic(dir, version) ? "yarn-classic" : "yarn"; +} + +/** A package inside a workspace has no lockfile of its own; the workspace root above it does. */ +function fromProject(dir: string, packageManagerField: string | undefined): PackageManager | undefined { + const lockfile = LOCKFILES.find((candidate) => fs.existsSync(path.join(dir, candidate.file))); + if (lockfile) return lockfile.packageManager === "yarn" ? resolveYarn(dir, undefined) : lockfile.packageManager; + + const declared = (packageManagerField ?? readPackageManagerField(dir))?.match(/^(npm|pnpm|yarn|bun)@?(\S+)?/); + if (!declared) return undefined; + return declared[1] === "yarn" ? resolveYarn(dir, declared[2]) : (declared[1] as PackageManager); +} + function fromUserAgent(targetDir: string, userAgent: string): PackageManager | undefined { const match = userAgent.match(/^(npm|pnpm|yarn|bun)\/(\S+)/); if (!match) return undefined; @@ -34,18 +72,19 @@ function fromUserAgent(targetDir: string, userAgent: string): PackageManager | u * The lockfile in the project wins over the tool that started us: `npm init mobilewright` * inside a pnpm project should still install with pnpm. */ -export function detectPackageManager(targetDir: string, packageManagerField?: string, userAgent = process.env.npm_config_user_agent): PackageManager { - const lockfile = LOCKFILES.find((candidate) => fs.existsSync(path.join(targetDir, candidate.file))); - const declared = packageManagerField?.match(/^(npm|pnpm|yarn|bun)@?(\S+)?/); - const detected = lockfile?.packageManager ?? (declared ? (declared[1] as PackageManager) : undefined); - - if (detected === "yarn") { - return isYarnClassic(targetDir, declared?.[1] === "yarn" ? declared[2] : undefined) ? "yarn-classic" : "yarn"; +export function detectPackageManager(targetDir: string, packageManagerField?: string, userAgent = process.env.npm_config_user_agent, homeDir = os.homedir()): PackageManager { + for (const dir of ancestorDirs(targetDir, homeDir)) { + const detected = fromProject(dir, dir === targetDir ? packageManagerField : undefined); + if (detected) return detected; } - if (detected) return detected; return (userAgent ? fromUserAgent(targetDir, userAgent) : undefined) ?? "npm"; } +// pnpm workspaces live in pnpm-workspace.yaml, every other manager declares them in package.json +export function isWorkspaceRoot(targetDir: string, workspacesField: unknown): boolean { + return workspacesField !== undefined || fs.existsSync(path.join(targetDir, "pnpm-workspace.yaml")); +} + // yarn 1 and pnpm refuse to add a dependency in a workspace root without this flag function workspaceFlag(packageManager: PackageManager, isWorkspaceRoot: boolean): string { if (!isWorkspaceRoot) return ""; diff --git a/src/project.ts b/src/project.ts index d2803e3..d5eb352 100644 --- a/src/project.ts +++ b/src/project.ts @@ -121,8 +121,7 @@ export function planInstall(pkg: PackageJson, language: Language, nodeVersion: s }; } -export function installCommands({ dependencies, devDependencies }: InstallPlan, packageManager: PackageManager, pkg: PackageJson = {}): string[] { - const isWorkspaceRoot = pkg.workspaces !== undefined; +export function installCommands({ dependencies, devDependencies }: InstallPlan, packageManager: PackageManager, isWorkspaceRoot = false): string[] { const commands = [ ...(devDependencies.length > 0 ? [installDevCommand(packageManager, devDependencies, isWorkspaceRoot)] : []), ...(dependencies.length > 0 ? [installProdCommand(packageManager, dependencies, isWorkspaceRoot)] : []), diff --git a/test/package-manager.test.ts b/test/package-manager.test.ts index 2750a1e..4d27f6d 100644 --- a/test/package-manager.test.ts +++ b/test/package-manager.test.ts @@ -3,12 +3,15 @@ import assert from "node:assert/strict"; import { createProjectCommand, detectPackageManager, + isWorkspaceRoot, installAllCommand, installDevCommand, installProdCommand, PackageManager, runCommand, } from "../src/package-manager"; +import fs from "fs"; +import path from "path"; import { createTempDir, writeFile } from "./helpers"; const NPM_USER_AGENT = "npm/11.19.0 node/v24.21.0 darwin arm64 workspaces/false"; @@ -48,6 +51,35 @@ test("otherwise the tool that started us decides", () => { assert.equal(detectPackageManager(createTempDir(), undefined, "deno/2.0.0"), "npm"); }); +test("a package inside a workspace uses the workspace root's package manager", () => { + const root = projectWith({ "pnpm-lock.yaml": "", "pnpm-workspace.yaml": "packages:\n - packages/*" }); + const child = path.join(root, "packages", "app"); + fs.mkdirSync(child, { recursive: true }); + writeFile(child, "package.json", '{"name":"app"}'); + + assert.equal(detectPackageManager(child, undefined, NPM_USER_AGENT), "pnpm"); + assert.equal(detectPackageManager(root, undefined, NPM_USER_AGENT), "pnpm"); +}); + +test("a lockfile outside the repository, for example in the home directory, is ignored", () => { + const home = createTempDir("home"); + writeFile(home, "yarn.lock", ""); + const project = path.join(home, "my-project"); + fs.mkdirSync(path.join(project, ".git"), { recursive: true }); + writeFile(project, "package.json", '{"name":"my-project"}'); + const nested = path.join(project, "e2e"); + fs.mkdirSync(nested); + + assert.equal(detectPackageManager(project, undefined, NPM_USER_AGENT, home), "npm"); + assert.equal(detectPackageManager(nested, undefined, NPM_USER_AGENT, home), "npm"); +}); + +test("pnpm workspace roots are recognized without a workspaces field in package.json", () => { + assert.equal(isWorkspaceRoot(projectWith({ "pnpm-workspace.yaml": "packages:\n - packages/*" }), undefined), true); + assert.equal(isWorkspaceRoot(createTempDir(), ["packages/*"]), true); + assert.equal(isWorkspaceRoot(createTempDir(), undefined), false); +}); + test("each package manager gets its own install commands", () => { const expected: Record = { npm: ['npm install --save-dev --include=dev "mobilewright@latest"', "npm install --include=dev"], From 083a19c5a79e34aed4619c1d1c758eb4e2f4f2b8 Mon Sep 17 00:00:00 2001 From: gmegidish Date: Mon, 21 Sep 2026 17:57:59 +0200 Subject: [PATCH 3/3] fix: use the declared yarn version to tell classic from berry when a yarn.lock exists --- src/package-manager.ts | 8 +++++--- test/package-manager.test.ts | 6 ++++++ 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/package-manager.ts b/src/package-manager.ts index 40cb06b..1260ad9 100644 --- a/src/package-manager.ts +++ b/src/package-manager.ts @@ -52,12 +52,14 @@ function resolveYarn(dir: string, version: string | undefined): PackageManager { /** A package inside a workspace has no lockfile of its own; the workspace root above it does. */ function fromProject(dir: string, packageManagerField: string | undefined): PackageManager | undefined { + const declared = (packageManagerField ?? readPackageManagerField(dir))?.match(/^(npm|pnpm|yarn|bun)@?(\S+)?/); + const declaredYarnVersion = declared?.[1] === "yarn" ? declared[2] : undefined; + const lockfile = LOCKFILES.find((candidate) => fs.existsSync(path.join(dir, candidate.file))); - if (lockfile) return lockfile.packageManager === "yarn" ? resolveYarn(dir, undefined) : lockfile.packageManager; + if (lockfile) return lockfile.packageManager === "yarn" ? resolveYarn(dir, declaredYarnVersion) : lockfile.packageManager; - const declared = (packageManagerField ?? readPackageManagerField(dir))?.match(/^(npm|pnpm|yarn|bun)@?(\S+)?/); if (!declared) return undefined; - return declared[1] === "yarn" ? resolveYarn(dir, declared[2]) : (declared[1] as PackageManager); + return declared[1] === "yarn" ? resolveYarn(dir, declaredYarnVersion) : (declared[1] as PackageManager); } function fromUserAgent(targetDir: string, userAgent: string): PackageManager | undefined { diff --git a/test/package-manager.test.ts b/test/package-manager.test.ts index 4d27f6d..1a14c3c 100644 --- a/test/package-manager.test.ts +++ b/test/package-manager.test.ts @@ -37,6 +37,12 @@ test("a yarn.lock without .yarnrc.yml means yarn classic, with it means berry", assert.equal(detectPackageManager(projectWith({ "yarn.lock": "", ".yarnrc.yml": "nodeLinker: pnp" })), "yarn"); }); +test("a declared yarn version decides classic vs berry, even with a yarn.lock", () => { + assert.equal(detectPackageManager(projectWith({ "yarn.lock": "", "package.json": '{"packageManager":"yarn@4.18.0"}' })), "yarn"); + assert.equal(detectPackageManager(projectWith({ "yarn.lock": "", "package.json": '{"packageManager":"yarn@1.22.22"}' })), "yarn-classic"); + assert.equal(detectPackageManager(projectWith({ "yarn.lock": "" }), "yarn@4.18.0"), "yarn"); +}); + test("the packageManager field is used when there is no lockfile", () => { assert.equal(detectPackageManager(createTempDir(), "pnpm@9.12.0", NPM_USER_AGENT), "pnpm"); assert.equal(detectPackageManager(createTempDir(), "yarn@4.18.0"), "yarn");