Skip to content

Commit b741b72

Browse files
authored
feat: support corepack packageManager and add related unit tests (electron-userland#9309)
- This rewrites the `yarn` parser to no longer use `npm list` and instead use `yarn list`. The JSON format is entirely different and doesn't possess the same tree setup that `npm list` returns, but in this migration, we can officially support `corepack` for yarn **classic** (as `npm list` won't run if `corepack enable` has run). - Adds new functionality, dedicated support for Yarn Berry. Only Yarn v1 can use CLI. We should use pnp.cjs for PnP, but we can't access the files due to virtual file paths within zipped modules. (e.g. `<package_name>.zip/<file_path>`) We fallback to npm node module collection (since Yarn Berry could have npm-like structure OR pnpm-like structure, depending on `nmHoistingLimits` configuration). In the latter case, we still can't assume `pnpm` is installed, so we still try to use npm collection as a best-effort attempt. - If all options fail for npm or yarn berry....added new functionality to parse the node_module tree manually as the ultimate fallback 😅 All filesystem operations are async and memoized. Migrated `packTester` to leverage `corepack` for isolating text/fixture environments and the various installation/hoisting setups they can have. Using specific installation methods in new unit tests (+ snapshot verification) of package manager implementations
1 parent 16c8fa1 commit b741b72

180 files changed

Lines changed: 61473 additions & 42913 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/legal-adults-fail.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"electron-builder": minor
3+
"app-builder-lib": minor
4+
---
5+
6+
feat: support `corepack` and `packageManager` field and add related unit tests

.github/workflows/test.yaml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ jobs:
7070
- ArtifactPublisherTest,BuildTest,ExtraBuildTest,RepoSlugTest,binDownloadTest,configurationValidationTest,filenameUtilTest,filesTest,globTest,httpExecutorTest,ignoreTest,macroExpanderTest,mainEntryTest,urlUtilTest,extraMetadataTest,linuxArchiveTest,linuxPackagerTest,HoistedNodeModuleTest,MemoLazyTest,HoistTest,ExtraBuildResourcesTest,utilTest
7171
- snapTest,debTest,fpmTest,protonTest
7272
- winPackagerTest,winCodeSignTest,webInstallerTest
73-
- oneClickInstallerTest,assistedInstallerTest
73+
- oneClickInstallerTest,assistedInstallerTest,packageManagerTest
7474
- concurrentBuildsTest
7575
steps:
7676
- name: Checkout code repository
@@ -218,7 +218,7 @@ jobs:
218218
fail-fast: false
219219
matrix:
220220
testFiles:
221-
- oneClickInstallerTest,assistedInstallerTest,webInstallerTest
221+
- oneClickInstallerTest,assistedInstallerTest,webInstallerTest,packageManagerTest,HoistedNodeModuleTest
222222
- winPackagerTest,winCodeSignTest,BuildTest,blackboxUpdateTest
223223
- masTest,dmgTest,filesTest,macPackagerTest,differentialUpdateTest,macArchiveTest
224224
- concurrentBuildsTest

.vscode/launch.json

Lines changed: 20 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,25 @@
11
{
22
"version": "0.2.0",
33
"configurations": [
4-
{
5-
"type": "node",
6-
"request": "launch",
7-
"name": "Vitest TEST_FILES",
8-
"runtimeExecutable": "pnpm",
9-
"program": "ci:test",
10-
"console": "integratedTerminal",
11-
"internalConsoleOptions": "openOnFirstSessionStart",
12-
"env": {
13-
"TEST_FILES": "macPackagerTest",
14-
"UPDATE_SNAPSHOT": "false"
15-
}
16-
}
4+
{
5+
"type": "node",
6+
"request": "launch",
7+
"name": "Vitest TEST_FILES",
8+
"runtimeExecutable": "pnpm",
9+
"program": "ci:test",
10+
"console": "integratedTerminal",
11+
"internalConsoleOptions": "openOnFirstSessionStart",
12+
"env": {
13+
"TEST_FILES": "packageManagerTest",
14+
"UPDATE_SNAPSHOT": "false",
15+
"DEBUG": "electron-builder",
16+
"TEST_SEQUENTIAL": "true" // Run tests sequentially to debug issues that may be hidden by concurrency (console log pollution when DEBUG flag set)
17+
},
18+
"skipFiles": [
19+
"<node_internals>/**", // Skip Node’s internal modules
20+
"${workspaceFolder}/**/node_modules/**/*.js", // Skip libraries
21+
"**/*.js" // Optionally skip all compiled JS
22+
]
23+
}
1724
]
1825
}

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
"prettier": "prettier 'packages/**/*.{ts,js}' test/src/**/*.ts --write",
2020
"///": "Please see https://github.com/electron-userland/electron-builder/blob/master/CONTRIBUTING.md#run-test-using-cli how to run particular test instead full (and very slow) run",
2121
"test-all": "pnpm compile && pnpm pretest && pnpm ci:test",
22-
"test-linux": "docker run --rm -e CI=${CI:-1} -e DEBUG=${DEBUG:-} -e UPDATE_SNAPSHOT=${UPDATE_SNAPSHOT:-false} -e TEST_FILES=\"${TEST_FILES:-HoistedNodeModuleTest}\" -w /project -v $(pwd):/project -v $(pwd)-node-modules:/project/node_modules -v $HOME/Library/Caches/electron:/root/.cache/electron -v $HOME/Library/Caches/electron-builder:/root/.cache/electron-builder ${ADDITIONAL_DOCKER_ARGS} ${TEST_RUNNER_IMAGE_TAG:-electronuserland/builder:22-wine-mono} /bin/bash -c \"corepack enable && pnpm install && pnpm ci:test\"",
22+
"test-linux": "docker run --rm -e CI=${CI:-true} -e DEBUG=${DEBUG:-} -e UPDATE_SNAPSHOT=${UPDATE_SNAPSHOT:-false} -e TEST_FILES=\"${TEST_FILES:-HoistedNodeModuleTest}\" -w /project -v $(pwd):/project -v $(pwd)-node-modules:/project/node_modules -v $HOME/Library/Caches/electron:/root/.cache/electron -v $HOME/Library/Caches/electron-builder:/root/.cache/electron-builder ${ADDITIONAL_DOCKER_ARGS} ${TEST_RUNNER_IMAGE_TAG:-electronuserland/builder:22-wine-mono} /bin/bash -c \"corepack enable && pnpm install && pnpm ci:test\"",
2323
"test-update": "UPDATE_SNAPSHOT=true vitest run",
2424
"test-ui": "vitest --ui",
2525
"docker-images": "docker/build.sh",
@@ -28,7 +28,7 @@
2828
"generate-changeset": "pnpm changeset",
2929
"generate-schema": "typescript-json-schema packages/app-builder-lib/tsconfig-scheme.json Configuration --out packages/app-builder-lib/scheme.json --noExtraProps --useTypeOfKeyword --strictNullChecks --required && node ./scripts/fix-schema.js",
3030
"generate-all": "pnpm generate-schema && pnpm prettier",
31-
"ci:test": "vitest run --no-update",
31+
"ci:test": "vitest run --no-update --allowOnly",
3232
"ci:version": "pnpm i && pnpm changelog && changeset version && node scripts/update-package-version-export.js && pnpm compile && pnpm generate-all && git add .",
3333
"ci:publish": "pnpm i && pnpm compile && pnpm publish -r --tag next && changeset tag",
3434
"docs:prebuild": "docker build -t mkdocs-dockerfile -f mkdocs-dockerfile . ",

packages/app-builder-lib/src/asar/asarUtil.ts

Lines changed: 91 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { PlatformPackager } from "../platformPackager"
99
import { ResolvedFileSet, getDestinationPath } from "../util/appFileCopier"
1010
import { detectUnpackedDirs } from "./unpackDetector"
1111
import { Readable } from "stream"
12+
import * as os from "os"
1213

1314
/** @internal */
1415
export class AsarPackager {
@@ -122,7 +123,7 @@ export class AsarPackager {
122123
transformedData: string | Buffer | undefined
123124
isUnpacked: (path: string) => boolean
124125
}): Promise<AsarStreamType> {
125-
const { isUnpacked, transformedData, file, destination, stat, fileSet } = options
126+
const { isUnpacked, transformedData, file, destination, stat } = options
126127
const unpacked = isUnpacked(destination)
127128

128129
if (!stat.isFile() && !stat.isSymbolicLink()) {
@@ -143,13 +144,8 @@ export class AsarPackager {
143144
return { path: destination, streamGenerator, unpacked, type: "file", stat: { mode: stat.mode, size } }
144145
}
145146

146-
const realPathFile = await fs.realpath(file)
147-
const realPathRelative = path.relative(fileSet.src, realPathFile)
148-
const isOutsidePackage = realPathRelative.startsWith("..")
149-
if (isOutsidePackage) {
150-
log.error({ source: log.filePath(file), realPathFile: log.filePath(realPathFile) }, `unable to copy, file is symlinked outside the package`)
151-
throw new Error(`Cannot copy file (${path.basename(file)}) symlinked to file (${path.basename(realPathFile)}) outside the package as that violates asar security integrity`)
152-
}
147+
// verify that the file is not a direct link or symlinked to access/copy a system file
148+
await this.protectSystemAndUnsafePaths(file)
153149

154150
const config = {
155151
path: destination,
@@ -158,14 +154,17 @@ export class AsarPackager {
158154
stat,
159155
}
160156

161-
// not a symlink, stream directly
162-
if (file === realPathFile) {
157+
// file, stream directly
158+
if (!stat.isSymbolicLink()) {
163159
return {
164160
...config,
165161
type: "file",
166162
}
167163
}
168164

165+
// guard against symlink pointing to outside workspace root
166+
await this.protectSystemAndUnsafePaths(file, await this.packager.info.getWorkspaceRoot())
167+
169168
// okay, it must be a symlink. evaluate link to be relative to source file in asar
170169
let link = await readlink(file)
171170
if (path.isAbsolute(link)) {
@@ -230,4 +229,86 @@ export class AsarPackager {
230229
transformedFiles,
231230
}
232231
}
232+
233+
private async getProtectedPaths(): Promise<string[]> {
234+
const systemPaths = [
235+
// Generic *nix
236+
"/usr",
237+
"/lib",
238+
"/bin",
239+
"/sbin",
240+
"/System",
241+
"/Library",
242+
"/private/etc",
243+
"/private/var/db",
244+
"/private/var/root",
245+
"/private/var/log",
246+
"/private/tmp",
247+
248+
// macOS legacy symlinks
249+
"/etc",
250+
"/var",
251+
"/tmp",
252+
253+
// Windows
254+
process.env.SystemRoot,
255+
process.env.WINDIR,
256+
// process.env.ProgramFiles,
257+
// process.env["ProgramFiles(x86)"],
258+
// process.env.ProgramData,
259+
// process.env.CommonProgramFiles,
260+
// process.env["CommonProgramFiles(x86)"],
261+
]
262+
.filter(Boolean)
263+
.map(p => path.resolve(p as string))
264+
265+
// Normalize to real paths to prevent symlink bypasses
266+
const resolvedPaths: string[] = []
267+
for (const p of systemPaths) {
268+
try {
269+
resolvedPaths.push(await fs.realpath(p))
270+
} catch {
271+
resolvedPaths.push(path.resolve(p))
272+
}
273+
}
274+
275+
return resolvedPaths
276+
}
277+
278+
private async protectSystemAndUnsafePaths(file: string, workspaceRoot?: string): Promise<boolean> {
279+
const resolved = await fs.realpath(file).catch(() => path.resolve(file))
280+
281+
const scan = async () => {
282+
if (workspaceRoot) {
283+
const workspace = path.resolve(workspaceRoot)
284+
285+
if (!resolved.startsWith(workspace)) {
286+
return true
287+
}
288+
}
289+
290+
// Allow temp & cache folders
291+
const tmpdir = await fs.realpath(os.tmpdir())
292+
if (resolved.startsWith(tmpdir)) {
293+
return false
294+
}
295+
296+
const blockedSystemPaths = await this.getProtectedPaths()
297+
for (const sys of blockedSystemPaths) {
298+
if (resolved.startsWith(sys)) {
299+
return true
300+
}
301+
}
302+
303+
return false
304+
}
305+
306+
const unsafe = await scan()
307+
308+
if (unsafe) {
309+
log.error({ source: file, realPath: resolved }, `unable to copy, file is from outside the package to a system or unsafe path`)
310+
throw new Error(`Cannot copy file [${file}] symlinked to file [${resolved}] outside the package to a system or unsafe path`)
311+
}
312+
return unsafe
313+
}
233314
}
Lines changed: 119 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,147 @@
1+
import { exists, log, spawn } from "builder-util"
2+
import { CancellationToken } from "builder-util-runtime"
3+
import * as fs from "fs-extra"
4+
import * as path from "path"
5+
import { TmpDir } from "temp-file"
16
import { NpmNodeModulesCollector } from "./npmNodeModulesCollector"
7+
import { detectYarnBerry as detectIfYarnBerry, detectPackageManagerByEnv, detectPackageManagerByFile, getPackageManagerCommand, PM } from "./packageManager"
28
import { PnpmNodeModulesCollector } from "./pnpmNodeModulesCollector"
3-
import { YarnNodeModulesCollector } from "./yarnNodeModulesCollector"
4-
import { detectPackageManagerByLockfile, detectPackageManagerByEnv, PM, getPackageManagerCommand, detectYarnBerry } from "./packageManager"
59
import { NodeModuleInfo } from "./types"
6-
import { TmpDir } from "temp-file"
10+
import { YarnBerryNodeModulesCollector } from "./yarnBerryNodeModulesCollector"
11+
import { YarnNodeModulesCollector } from "./yarnNodeModulesCollector"
12+
13+
export { getPackageManagerCommand, PM }
714

8-
export async function getCollectorByPackageManager(pm: PM, rootDir: string, tempDirManager: TmpDir) {
15+
export function getCollectorByPackageManager(pm: PM, rootDir: string, tempDirManager: TmpDir) {
916
switch (pm) {
1017
case PM.PNPM:
11-
if (await PnpmNodeModulesCollector.isPnpmProjectHoisted(rootDir)) {
12-
return new NpmNodeModulesCollector(rootDir, tempDirManager)
13-
}
1418
return new PnpmNodeModulesCollector(rootDir, tempDirManager)
15-
case PM.NPM:
16-
case PM.BUN:
17-
return new NpmNodeModulesCollector(rootDir, tempDirManager)
1819
case PM.YARN:
1920
return new YarnNodeModulesCollector(rootDir, tempDirManager)
21+
case PM.YARN_BERRY:
22+
return new YarnBerryNodeModulesCollector(rootDir, tempDirManager)
23+
case PM.BUN:
24+
case PM.NPM:
2025
default:
2126
return new NpmNodeModulesCollector(rootDir, tempDirManager)
2227
}
2328
}
2429

25-
export async function getNodeModules(pm: PM, rootDir: string, tempDirManager: TmpDir): Promise<NodeModuleInfo[]> {
26-
const collector = await getCollectorByPackageManager(pm, rootDir, tempDirManager)
27-
return collector.getNodeModules()
30+
export function getNodeModules(
31+
pm: PM,
32+
{
33+
rootDir,
34+
tempDirManager,
35+
cancellationToken,
36+
packageName,
37+
}: {
38+
rootDir: string
39+
tempDirManager: TmpDir
40+
cancellationToken: CancellationToken
41+
packageName: string
42+
}
43+
): Promise<NodeModuleInfo[]> {
44+
const collector = getCollectorByPackageManager(pm, rootDir, tempDirManager)
45+
return collector.getNodeModules({ cancellationToken, packageName })
2846
}
2947

30-
export function detectPackageManager(dirs: string[]): PM {
48+
export async function detectPackageManager(searchPaths: string[]): Promise<{ pm: PM; corepackConfig: string | undefined; resolvedDirectory: string | undefined }> {
3149
let pm: PM | null = null
50+
const dedupedPaths = Array.from(new Set(searchPaths)) // reduce file operations, dedupe paths since primary use case has projectDir === appDir
3251

33-
const resolveYarnVersion = (pm: PM) => {
34-
if (pm === PM.YARN) {
35-
return detectYarnBerry()
52+
const resolveIfYarn = (pm: PM, version: string, cwd: string) => (pm === PM.YARN ? detectIfYarnBerry(cwd, version) : pm)
53+
54+
for (const dir of dedupedPaths) {
55+
const packageJsonPath = path.join(dir, "package.json")
56+
const packageManager = (await exists(packageJsonPath)) ? (await fs.readJson(packageJsonPath, "utf8"))?.packageManager : undefined
57+
if (packageManager) {
58+
const [pm, version] = packageManager.split("@")
59+
if (Object.values(PM).includes(pm as PM)) {
60+
const resolvedPackageManager = await resolveIfYarn(pm as PM, version, dir)
61+
log.debug({ resolvedPackageManager, packageManager, cwd: dir }, "packageManager field detected in package.json")
62+
return { pm: resolvedPackageManager, corepackConfig: packageManager, resolvedDirectory: dir }
63+
}
3664
}
37-
return pm
38-
}
3965

40-
for (const dir of dirs) {
41-
pm = detectPackageManagerByLockfile(dir)
66+
pm = await detectPackageManagerByFile(dir)
4267
if (pm) {
43-
return resolveYarnVersion(pm)
68+
const resolvedPackageManager = await resolveIfYarn(pm, "", dir)
69+
log.debug({ resolvedPackageManager, cwd: dir }, "packageManager detected by file")
70+
return { pm: resolvedPackageManager, resolvedDirectory: dir, corepackConfig: undefined }
4471
}
4572
}
4673

47-
pm = detectPackageManagerByEnv()
48-
if (pm) {
49-
return resolveYarnVersion(pm)
74+
pm = detectPackageManagerByEnv() || PM.NPM
75+
const cwd = process.env.npm_package_json ? path.dirname(process.env.npm_package_json) : (process.env.INIT_CWD ?? process.cwd())
76+
const resolvedPackageManager = await resolveIfYarn(pm, "", cwd)
77+
log.debug({ resolvedPackageManager, detected: cwd }, "packageManager not detected by file, falling back to environment detection")
78+
return { pm: resolvedPackageManager, resolvedDirectory: undefined, corepackConfig: undefined }
79+
}
80+
81+
export async function findWorkspaceRoot(pm: PM, cwd: string): Promise<string | undefined> {
82+
let command: { command: string; args: string[] } | undefined
83+
84+
switch (pm) {
85+
case PM.PNPM:
86+
command = { command: "pnpm", args: ["root", "-w"] }
87+
break
88+
89+
case PM.YARN_BERRY:
90+
command = { command: "yarn", args: ["config", "get", "workspaceRoot"] }
91+
break
92+
93+
case PM.YARN: {
94+
command = { command: "yarn", args: ["workspaces", "info", "--silent"] }
95+
break
96+
}
97+
98+
case PM.BUN:
99+
command = { command: "bun", args: ["pm", "ls", "--json"] }
100+
break
101+
102+
case PM.NPM:
103+
default:
104+
command = { command: "npm", args: ["prefix", "-w"] }
105+
break
50106
}
51107

52-
// Default to npm
53-
return PM.NPM
108+
const output = await spawn(command.command, command.args, { cwd, stdio: ["ignore", "pipe", "ignore"] })
109+
.then(it => {
110+
const out = it?.trim()
111+
if (pm === PM.YARN) {
112+
JSON.parse(out) // if JSON valid, workspace detected
113+
return findNearestWithWorkspacesField(cwd)
114+
} else if (pm === PM.BUN) {
115+
const json = JSON.parse(out)
116+
if (Array.isArray(json) && json.length > 0) {
117+
return findNearestWithWorkspacesField(cwd)
118+
}
119+
}
120+
return !out?.length || out === "undefined" ? undefined : out
121+
})
122+
.catch(() => findNearestWithWorkspacesField(cwd))
123+
124+
log.debug({ root: output || cwd }, output ? "workspace root detected" : "workspace root not detected, using project root")
125+
return output
54126
}
55127

56-
export { PM, getPackageManagerCommand }
128+
async function findNearestWithWorkspacesField(dir: string): Promise<string | undefined> {
129+
let current = dir
130+
while (true) {
131+
const pkgPath = path.join(current, "package.json")
132+
try {
133+
const pkg = JSON.parse(await fs.readFile(pkgPath, "utf8"))
134+
if (pkg.workspaces) {
135+
return current
136+
}
137+
} catch {
138+
// ignore
139+
}
140+
const parent = path.dirname(current)
141+
if (parent === current) {
142+
break
143+
}
144+
current = parent
145+
}
146+
return undefined
147+
}

0 commit comments

Comments
 (0)