Skip to content

refactor: update package manager detection and improve type handling #9067

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
May 4, 2025
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
5 changes: 5 additions & 0 deletions .changeset/blue-icons-push.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"app-builder-lib": patch
---

refactor: update package manager detection and improve type handling
12 changes: 6 additions & 6 deletions packages/app-builder-lib/src/node-module-collector/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { NpmNodeModulesCollector } from "./npmNodeModulesCollector"
import { PnpmNodeModulesCollector } from "./pnpmNodeModulesCollector"
import { YarnNodeModulesCollector } from "./yarnNodeModulesCollector"
import { detect, PM, getPackageManagerVersion } from "./packageManager"
import { detectPackageManager, PM, getPackageManagerCommand } from "./packageManager"
import { NodeModuleInfo } from "./types"
import { exec } from "builder-util"

Expand All @@ -13,16 +13,16 @@ async function isPnpmProjectHoisted(rootDir: string) {
}

export async function getCollectorByPackageManager(rootDir: string) {
const manager: PM = await detect({ cwd: rootDir })
const manager: PM = detectPackageManager(rootDir)
switch (manager) {
case "pnpm":
case PM.PNPM:
if (await isPnpmProjectHoisted(rootDir)) {
return new NpmNodeModulesCollector(rootDir)
}
return new PnpmNodeModulesCollector(rootDir)
case "npm":
case PM.NPM:
return new NpmNodeModulesCollector(rootDir)
case "yarn":
case PM.YARN:
return new YarnNodeModulesCollector(rootDir)
default:
return new NpmNodeModulesCollector(rootDir)
Expand All @@ -34,4 +34,4 @@ export async function getNodeModules(rootDir: string): Promise<NodeModuleInfo[]>
return collector.getNodeModules()
}

export { detect, getPackageManagerVersion, PM }
export { detectPackageManager, PM, getPackageManagerCommand }
156 changes: 82 additions & 74 deletions packages/app-builder-lib/src/node-module-collector/packageManager.ts
Original file line number Diff line number Diff line change
@@ -1,95 +1,103 @@
// copy from https://github.com/egoist/detect-package-manager/blob/main/src/index.ts
// and merge https://github.com/egoist/detect-package-manager/pull/9 to support Monorepo
import { resolve, dirname } from "path"
import { exec, exists } from "builder-util"

export type PM = "npm" | "yarn" | "pnpm" | "bun"

const cache = new Map()
const globalInstallationCache = new Map<string, boolean>()
const lockfileCache = new Map<string, PM>()

/**
* Check if a global pm is available
*/
function hasGlobalInstallation(pm: PM): Promise<boolean> {
const key = `has_global_${pm}`
if (globalInstallationCache.has(key)) {
return Promise.resolve(globalInstallationCache.get(key)!)
}
import * as path from "path"
import * as fs from "fs"

return exec(pm, ["--version"], { shell: true })
.then(res => {
return /^\d+.\d+.\d+$/.test(res)
})
.then(value => {
globalInstallationCache.set(key, value)
return value
})
.catch(() => false)
export enum PM {
NPM = "npm",
YARN = "yarn",
PNPM = "pnpm",
YARN_BERRY = "yarn-berry",
}

function getTypeofLockFile(cwd = process.cwd()): Promise<PM> {
const key = `lockfile_${cwd}`
if (lockfileCache.has(key)) {
return Promise.resolve(lockfileCache.get(key)!)
}
function detectPackageManagerByEnv(): PM {
if (process.env.npm_config_user_agent) {
const userAgent = process.env.npm_config_user_agent

return Promise.all([
exists(resolve(cwd, "yarn.lock")),
exists(resolve(cwd, "package-lock.json")),
exists(resolve(cwd, "pnpm-lock.yaml")),
exists(resolve(cwd, "bun.lockb")),
]).then(([isYarn, _, isPnpm, isBun]) => {
let value: PM

if (isYarn) {
value = "yarn"
} else if (isPnpm) {
value = "pnpm"
} else if (isBun) {
value = "bun"
} else {
value = "npm"
if (userAgent.includes("pnpm")) {
return PM.PNPM
}

cache.set(key, value)
return value
})
}
if (userAgent.includes("yarn")) {
if (userAgent.includes("yarn/")) {
const version = userAgent.match(/yarn\/(\d+)\./)
if (version && parseInt(version[1]) >= 2) {
return PM.YARN_BERRY
}
}
return PM.YARN
}

export const detect = async ({ cwd, includeGlobalBun }: { cwd?: string; includeGlobalBun?: boolean } = {}) => {
let type = await getTypeofLockFile(cwd)
if (type) {
return type
if (userAgent.includes("npm")) {
return PM.NPM
}
}

let tmpCwd = cwd || "."
for (let i = 1; i <= 5; i++) {
tmpCwd = dirname(tmpCwd)
type = await getTypeofLockFile(tmpCwd)
if (type) {
return type
if (process.env.npm_execpath) {
const execPath = process.env.npm_execpath.toLowerCase()

if (execPath.includes("pnpm")) {
return PM.PNPM
}

if (execPath.includes("yarn")) {
if (execPath.includes("berry") || process.env.YARN_VERSION?.startsWith("2.") || process.env.YARN_VERSION?.startsWith("3.")) {
return PM.YARN_BERRY
}
return PM.YARN
}

if (execPath.includes("npm")) {
return PM.NPM
}
}

if (await hasGlobalInstallation("yarn")) {
return "yarn"
if (process.env.PNPM_HOME) {
return PM.PNPM
}
if (await hasGlobalInstallation("pnpm")) {
return "yarn"

if (process.env.YARN_REGISTRY) {
if (process.env.YARN_VERSION?.startsWith("2.") || process.env.YARN_VERSION?.startsWith("3.")) {
return PM.YARN_BERRY
}
return PM.YARN
}

if (includeGlobalBun && (await hasGlobalInstallation("bun"))) {
return "bun"
if (process.env.npm_package_json) {
return PM.NPM
}
return "npm"

// return default
return PM.NPM
}

export function getPackageManagerVersion(pm: PM) {
return exec(pm, ["--version"], { shell: true }).then(res => res.trim())
export function getPackageManagerCommand(pm: PM) {
let cmd = pm
if (pm === PM.YARN_BERRY || process.env.FORCE_YARN === "true") {
cmd = PM.YARN
}
return `${cmd}${process.platform === "win32" ? ".cmd" : ""}`
}

export function clearCache() {
return cache.clear()
export function detectPackageManager(cwd: string) {
const isYarnLockFileExists = fs.existsSync(path.join(cwd, "yarn.lock"))
const isPnpmLockFileExists = fs.existsSync(path.join(cwd, "pnpm-lock.yaml"))
const isNpmLockFileExists = fs.existsSync(path.join(cwd, "package-lock.json"))

if (isYarnLockFileExists && !isPnpmLockFileExists && !isNpmLockFileExists) {
// check if yarn is berry
const pm = detectPackageManagerByEnv()
if (pm === PM.YARN_BERRY) {
return PM.YARN_BERRY
}
return PM.YARN
}

if (isPnpmLockFileExists && !isYarnLockFileExists && !isNpmLockFileExists) {
return PM.PNPM
}

if (isNpmLockFileExists && !isYarnLockFileExists && !isPnpmLockFileExists) {
return PM.NPM
}
// if there are no lock files or multiple lock files, return the package manager from env
return detectPackageManagerByEnv()
}
37 changes: 5 additions & 32 deletions packages/app-builder-lib/src/util/yarn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { homedir } from "os"
import * as path from "path"
import { Configuration } from "../configuration"
import { executeAppBuilderAndWriteJson } from "./appBuilder"
import { PM, detect, getPackageManagerVersion } from "../node-module-collector"
import { PM, detectPackageManager, getPackageManagerCommand } from "../node-module-collector"
import { NodeModuleDirInfo } from "./packageDependencies"
import { rebuild as remoteRebuild } from "./rebuild/rebuild"

Expand Down Expand Up @@ -78,38 +78,25 @@ export function getGypEnv(frameworkInfo: DesktopFrameworkInfo, platform: NodeJS.
}
}

async function checkYarnBerry(pm: PM) {
if (pm !== "yarn") {
return false
}
const version = await getPackageManagerVersion(pm)
if (version == null || version.split(".").length < 1) {
return false
}

return version.split(".")[0] >= "2"
}

async function installDependencies(config: Configuration, { appDir, projectDir }: DirectoryPaths, options: RebuildOptions): Promise<any> {
const platform = options.platform || process.platform
const arch = options.arch || process.arch
const additionalArgs = options.additionalArgs

const pm = await detect({ cwd: projectDir })
const pm = detectPackageManager(projectDir)
log.info({ pm, platform, arch, projectDir, appDir }, `installing production dependencies`)
const execArgs = ["install"]
const isYarnBerry = await checkYarnBerry(pm)
if (!isYarnBerry) {
if (pm === PM.YARN_BERRY) {
if (process.env.NPM_NO_BIN_LINKS === "true") {
execArgs.push("--no-bin-links")
}
}

if (!isRunningYarn(pm)) {
if (pm === PM.YARN) {
execArgs.push("--prefer-offline")
}

const execPath = getPackageToolPath(pm)
const execPath = getPackageManagerCommand(pm)

if (additionalArgs != null) {
execArgs.push(...additionalArgs)
Expand Down Expand Up @@ -142,20 +129,6 @@ export async function nodeGypRebuild(platform: NodeJS.Platform, arch: string, fr
}
await spawn(nodeGyp, args, { env: getGypEnv(frameworkInfo, platform, arch, true) })
}

function getPackageToolPath(pm: PM) {
let cmd = pm
if (process.env.FORCE_YARN === "true") {
cmd = "yarn"
}
return `${cmd}${process.platform === "win32" ? ".cmd" : ""}`
}

function isRunningYarn(pm: PM) {
const userAgent = process.env.npm_config_user_agent
return process.env.FORCE_YARN === "true" || pm === "yarn" || (userAgent != null && /\byarn\b/.test(userAgent))
}

export interface RebuildOptions {
frameworkInfo: DesktopFrameworkInfo
productionDeps: Lazy<Array<NodeModuleDirInfo>>
Expand Down
22 changes: 13 additions & 9 deletions test/src/BuildTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,8 +363,9 @@ test.ifDevOrLinuxCi("win smart unpack", ({ expect }) => {
},
{
isInstallDepsBefore: true,
projectDirCreated: projectDir => {
projectDirCreated: async projectDir => {
p = projectDir
process.env.npm_config_user_agent = "npm"
return packageJson(it => {
it.dependencies = {
debug: "3.1.0",
Expand Down Expand Up @@ -440,14 +441,17 @@ test.ifDevOrLinuxCi("posix smart unpack", ({ expect }) =>
},
{
isInstallDepsBefore: true,
projectDirCreated: packageJson(it => {
it.dependencies = {
debug: "4.1.1",
"edge-cs": "1.2.1",
keytar: "7.9.0",
three: "0.160.0",
}
}),
projectDirCreated: projectDir => {
process.env.npm_config_user_agent = "npm"
return packageJson(it => {
it.dependencies = {
debug: "4.1.1",
"edge-cs": "1.2.1",
keytar: "7.9.0",
three: "0.160.0",
}
})(projectDir)
},
packed: async context => {
expect(context.packager.appInfo.copyright).toBe("Copyright © 2018 Foo Bar")
await verifySmartUnpack(expect, context.getResources(Platform.LINUX), async asarFs => {
Expand Down
2 changes: 1 addition & 1 deletion test/src/HoistedNodeModuleTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ describe("isInstallDepsBefore=true", { sequential: true }, () => {
},
{
isInstallDepsBefore: true,
projectDirCreated: projectDir => {
projectDirCreated: async projectDir => {
const subAppDir = path.join(projectDir, "packages", "test-app")
return modifyPackageJson(subAppDir, data => {
data.name = "@scope/xxx-app"
Expand Down
19 changes: 12 additions & 7 deletions test/src/globTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ describe("isInstallDepsBefore=true", { sequential: true }, () => {
{
isInstallDepsBefore: true,
projectDirCreated: async projectDir => {
await outputFile(path.join(projectDir, "package-lock.json"), "")
await modifyPackageJson(projectDir, data => {
data.dependencies = {
debug: "4.1.1",
Expand Down Expand Up @@ -207,7 +208,7 @@ describe("isInstallDepsBefore=true", { sequential: true }, () => {
},
{
isInstallDepsBefore: true,
projectDirCreated: projectDir => {
projectDirCreated: async projectDir => {
return Promise.all([
modifyPackageJson(projectDir, data => {
//noinspection SpellCheckingInspection
Expand Down Expand Up @@ -241,15 +242,17 @@ describe("isInstallDepsBefore=true", { sequential: true }, () => {
},
{
isInstallDepsBefore: true,
projectDirCreated: projectDir =>
modifyPackageJson(projectDir, data => {
projectDirCreated: async projectDir => {
await outputFile(path.join(projectDir, "package-lock.json"), "")
return modifyPackageJson(projectDir, data => {
//noinspection SpellCheckingInspection
data.dependencies = {
"ci-info": "2.0.0",
// this contains string-width-cjs 4.2.3
"@isaacs/cliui": "8.0.2",
}
}),
})
},
packed: context => {
return assertThat(expect, path.join(context.getResources(Platform.LINUX), "app", "node_modules")).doesNotExist()
},
Expand All @@ -269,12 +272,14 @@ describe("isInstallDepsBefore=true", { sequential: true }, () => {
},
{
isInstallDepsBefore: true,
projectDirCreated: projectDir =>
modifyPackageJson(projectDir, data => {
projectDirCreated: async projectDir => {
await outputFile(path.join(projectDir, "package-lock.json"), "")
return modifyPackageJson(projectDir, data => {
data.dependencies = {
"ci-info": "2.0.0",
}
}),
})
},
packed: async context => {
const nodeModulesNode = (await readAsar(path.join(context.getResources(Platform.LINUX), "app.asar"))).getNode("node_modules")
expect(removeUnstableProperties(nodeModulesNode)).toMatchSnapshot()
Expand Down
Loading