From be0c97ec17ccb7a4465be1755751454115d9ea0f Mon Sep 17 00:00:00 2001 From: Elliot Taylor Date: Tue, 19 May 2026 20:53:26 +0100 Subject: [PATCH] Add yarn.lock parser (yarn classic v1 and berry v2+) Yarn is the last of the four major Node package managers we didn't yet read. This adds a zero-dep parser that handles both yarn classic (`version "x"`) and yarn berry (`version: x`, `__metadata` header, `npm:` descriptors) by walking the shared block structure and only branching on the value syntax. Yarn's lockfile, unlike pnpm v9's, doesn't record an importer manifest, so direct-vs-transitive marking is cross-referenced against the sibling `package.json`. Absent or unreadable package.json leaves all entries unmarked rather than failing the scan. Detection priority is npm > pnpm > yarn > bun, so a yarn migration in progress with multiple lockfiles still resolves deterministically and matches what the package manager itself would prefer. Co-Authored-By: Claude Opus 4.7 --- README.md | 2 +- src/parsers/index.ts | 19 +- src/parsers/yarn.ts | 252 ++++++++++++++++++ .../fixtures/yarn-berry-project/package.json | 14 + tests/fixtures/yarn-berry-project/yarn.lock | 52 ++++ tests/fixtures/yarn-v1-project/package.json | 14 + tests/fixtures/yarn-v1-project/yarn.lock | 42 +++ tests/yarn.test.ts | 239 +++++++++++++++++ 8 files changed, 628 insertions(+), 6 deletions(-) create mode 100644 src/parsers/yarn.ts create mode 100644 tests/fixtures/yarn-berry-project/package.json create mode 100644 tests/fixtures/yarn-berry-project/yarn.lock create mode 100644 tests/fixtures/yarn-v1-project/package.json create mode 100644 tests/fixtures/yarn-v1-project/yarn.lock create mode 100644 tests/yarn.test.ts diff --git a/README.md b/README.md index 63e15bf..0881842 100644 --- a/README.md +++ b/README.md @@ -117,9 +117,9 @@ That's the entire payload. No source code, no environment variables, no file pat - ✅ `package-lock.json` (npm v6 / v2 / v3) — parsed directly - ✅ `pnpm-lock.yaml` (pnpm v5 / v6 / v7 / v8 / v9) — parsed directly +- ✅ `yarn.lock` (yarn classic v1 and yarn berry v2+) — parsed directly - ✅ `bun.lockb` (binary) — package list resolved by walking `node_modules/` - ✅ `bun.lock` (text) — same fallback; direct parsing coming -- ❌ `yarn.lock` — coming soon If both a Bun lockfile and `node_modules/` are present, the connector walks `node_modules/` to enumerate the installed packages. Run `bun install` (or `npm install`) before scanning so the directory is populated. diff --git a/src/parsers/index.ts b/src/parsers/index.ts index d19c020..86e621d 100644 --- a/src/parsers/index.ts +++ b/src/parsers/index.ts @@ -4,6 +4,7 @@ import { PatchstackError, type Manifest, type PackageEntry } from '../types.js'; import { parseNpmLockfile } from './npm.js'; import { walkNodeModules } from './node_modules.js'; import { parsePnpmLockfile } from './pnpm.js'; +import { parseYarnLockfile } from './yarn.js'; type LockfileFilename = | 'package-lock.json' @@ -12,7 +13,11 @@ type LockfileFilename = | 'yarn.lock' | 'pnpm-lock.yaml'; -type DetectionStrategy = 'npm-lockfile' | 'node-modules-walk' | 'pnpm-lockfile'; +type DetectionStrategy = + | 'npm-lockfile' + | 'node-modules-walk' + | 'pnpm-lockfile' + | 'yarn-lockfile'; interface DetectedLockfile { ecosystem: 'npm'; @@ -64,10 +69,12 @@ export async function detectLockfile(cwd: string): Promise { const yarnLock = path.join(cwd, 'yarn.lock'); if (await exists(yarnLock)) { - throw new PatchstackError( - 'yarn.lock detected but not yet supported. Run `npm install` to generate a package-lock.json, or open an issue at github.com/patchstack/connect.', - 'LOCKFILE_UNSUPPORTED', - ); + return { + ecosystem: 'npm', + filePath: yarnLock, + filename: 'yarn.lock', + strategy: 'yarn-lockfile', + }; } throw new PatchstackError( @@ -91,6 +98,8 @@ async function runStrategy( return parseNpmLockfile(detected.filePath); case 'pnpm-lockfile': return parsePnpmLockfile(detected.filePath); + case 'yarn-lockfile': + return parseYarnLockfile(detected.filePath); case 'node-modules-walk': return walkNodeModules(cwd); } diff --git a/src/parsers/yarn.ts b/src/parsers/yarn.ts new file mode 100644 index 0000000..d2b1393 --- /dev/null +++ b/src/parsers/yarn.ts @@ -0,0 +1,252 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { PatchstackError, type PackageEntry } from '../types.js'; + +/** + * Parses yarn.lock (yarn classic v1 and yarn berry v2+) without a YAML + * dependency. Both generations share the same block structure — a top-level + * mapping of comma-separated descriptor lists to a block containing a + * `version` field — so we walk them with the same scanner and only branch on + * the `version` syntax (`version "x"` for v1, `version: x` for berry). + * + * Direct vs transitive can't be derived from yarn.lock alone (yarn does not + * record an importer manifest the way pnpm v9 does), so we cross-reference + * the sibling `package.json` when present. + */ +export async function parseYarnLockfile(lockfilePath: string): Promise { + let raw: string; + try { + raw = await readFile(lockfilePath, 'utf8'); + } catch (cause) { + throw new PatchstackError( + `Could not read lockfile at ${lockfilePath}`, + 'LOCKFILE_NOT_FOUND', + cause, + ); + } + + const blocks = parseBlocks(raw); + if (blocks.length === 0) { + throw new PatchstackError( + `Lockfile at ${lockfilePath} contains no package entries`, + 'LOCKFILE_PARSE_ERROR', + ); + } + + const directNames = await readDirectDepNames(path.dirname(lockfilePath)); + + const entries: PackageEntry[] = []; + const seen = new Set(); + for (const block of blocks) { + if (block.version.length === 0 || block.names.size === 0) { + continue; + } + for (const name of block.names) { + const dedupKey = `${name}@${block.version}`; + if (seen.has(dedupKey)) { + continue; + } + seen.add(dedupKey); + entries.push({ + name, + version: block.version, + direct: directNames.has(name), + }); + } + } + + return entries; +} + +interface Block { + names: Set; + version: string; +} + +function parseBlocks(raw: string): Block[] { + const lines = raw.split(/\r?\n/); + const blocks: Block[] = []; + let current: Block | null = null; + + const finalize = () => { + if (current !== null && current.version.length > 0 && current.names.size > 0) { + blocks.push(current); + } + current = null; + }; + + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.length === 0 || trimmed.startsWith('#')) { + continue; + } + + const indent = countLeadingSpaces(line); + + if (indent === 0) { + finalize(); + if (!trimmed.endsWith(':')) { + continue; + } + // `__metadata:` (yarn berry header) has no `@` in any descriptor and + // produces an empty names set, so it's naturally skipped on finalize. + const keyLine = trimmed.slice(0, -1); + const names = new Set(); + for (const spec of splitDescriptors(keyLine)) { + const name = extractName(spec); + if (name !== null) { + names.add(name); + } + } + current = { names, version: '' }; + continue; + } + + if (current === null) { + continue; + } + + const version = parseVersionField(trimmed); + if (version !== null) { + current.version = version; + } + } + + finalize(); + return blocks; +} + +function countLeadingSpaces(line: string): number { + let i = 0; + while (i < line.length && line[i] === ' ') { + i++; + } + return i; +} + +/** + * Splits a yarn descriptor key list on top-level commas. yarn quotes any + * descriptor that contains characters needing escaping, so we respect quotes + * while splitting to avoid breaking on commas inside (rare in practice but + * cheap to handle). + */ +export function splitDescriptors(keyLine: string): string[] { + const parts: string[] = []; + let current = ''; + let quote: '"' | "'" | null = null; + + for (let i = 0; i < keyLine.length; i++) { + const c = keyLine[i]; + if (quote !== null) { + current += c; + if (c === quote) { + quote = null; + } + continue; + } + if (c === '"' || c === "'") { + quote = c; + current += c; + continue; + } + if (c === ',') { + const piece = current.trim(); + if (piece.length > 0) { + parts.push(piece); + } + current = ''; + continue; + } + current += c; + } + const tail = current.trim(); + if (tail.length > 0) { + parts.push(tail); + } + return parts; +} + +/** + * Extracts the package name from a yarn descriptor like `axios@^1.6.0`, + * `"@scope/pkg@^2.1.0"`, or `"@scope/pkg@npm:2.1.0"`. The descriptor's + * range portion is discarded — we only need the name, since the resolved + * version comes from the `version` field of the block. + */ +export function extractName(rawSpec: string): string | null { + let s = rawSpec.trim(); + if (s.length === 0) { + return null; + } + if ( + (s.startsWith('"') && s.endsWith('"')) || + (s.startsWith("'") && s.endsWith("'")) + ) { + s = s.slice(1, -1); + } + // Position-0 `@` belongs to a scope, so we want the last `@` after it. + const atIdx = s.lastIndexOf('@'); + if (atIdx <= 0) { + return null; + } + const name = s.slice(0, atIdx); + return name.length > 0 ? name : null; +} + +function parseVersionField(content: string): string | null { + if (!content.startsWith('version')) { + return null; + } + const after = content.slice('version'.length); + // yarn v1: `version "1.2.3"` (whitespace then quoted) + // yarn berry: `version: 1.2.3` or `version: "1.2.3"` + const firstChar = after.charAt(0); + if (firstChar !== ' ' && firstChar !== '\t' && firstChar !== ':') { + return null; + } + let rest = firstChar === ':' ? after.slice(1) : after; + rest = rest.trim(); + if (rest.length === 0) { + return null; + } + if ( + (rest.startsWith('"') && rest.endsWith('"')) || + (rest.startsWith("'") && rest.endsWith("'")) + ) { + rest = rest.slice(1, -1); + } + return rest.length > 0 ? rest : null; +} + +async function readDirectDepNames(cwd: string): Promise> { + const names = new Set(); + let raw: string; + try { + raw = await readFile(path.join(cwd, 'package.json'), 'utf8'); + } catch { + return names; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return names; + } + + if (typeof parsed !== 'object' || parsed === null) { + return names; + } + const obj = parsed as Record; + + for (const field of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) { + const section = obj[field]; + if (typeof section !== 'object' || section === null) { + continue; + } + for (const name of Object.keys(section)) { + names.add(name); + } + } + + return names; +} diff --git a/tests/fixtures/yarn-berry-project/package.json b/tests/fixtures/yarn-berry-project/package.json new file mode 100644 index 0000000..ff1a2aa --- /dev/null +++ b/tests/fixtures/yarn-berry-project/package.json @@ -0,0 +1,14 @@ +{ + "name": "yarn-berry-project", + "version": "1.0.0", + "private": true, + "packageManager": "yarn@4.0.0", + "dependencies": { + "@scope/pkg": "^2.1.0", + "axios": "^1.6.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "vitest": "^3.0.0" + } +} diff --git a/tests/fixtures/yarn-berry-project/yarn.lock b/tests/fixtures/yarn-berry-project/yarn.lock new file mode 100644 index 0000000..69d525a --- /dev/null +++ b/tests/fixtures/yarn-berry-project/yarn.lock @@ -0,0 +1,52 @@ +# This file is generated by running "yarn install" inside your project. +# Manual changes might be lost - proceed with caution! + +__metadata: + version: 6 + cacheKey: 8 + +"@scope/pkg@npm:^2.1.0": + version: 2.1.0 + resolution: "@scope/pkg@npm:2.1.0" + checksum: fake + languageName: node + linkType: hard + +"axios@npm:^1.6.0": + version: 1.6.0 + resolution: "axios@npm:1.6.0" + dependencies: + follow-redirects: "npm:^1.15.0" + checksum: fake + languageName: node + linkType: hard + +"follow-redirects@npm:^1.15.0": + version: 1.15.3 + resolution: "follow-redirects@npm:1.15.3" + checksum: fake + languageName: node + linkType: hard + +"react@npm:^18.2.0": + version: 18.2.0 + resolution: "react@npm:18.2.0" + checksum: fake + languageName: node + linkType: hard + +"react-dom@npm:^18.2.0": + version: 18.2.0 + resolution: "react-dom@npm:18.2.0" + dependencies: + react: "npm:^18.2.0" + checksum: fake + languageName: node + linkType: hard + +"vitest@npm:^3.0.0": + version: 3.0.0 + resolution: "vitest@npm:3.0.0" + checksum: fake + languageName: node + linkType: hard diff --git a/tests/fixtures/yarn-v1-project/package.json b/tests/fixtures/yarn-v1-project/package.json new file mode 100644 index 0000000..0471db0 --- /dev/null +++ b/tests/fixtures/yarn-v1-project/package.json @@ -0,0 +1,14 @@ +{ + "name": "yarn-v1-project", + "version": "1.0.0", + "private": true, + "dependencies": { + "@scope/pkg": "^2.1.0", + "axios": "^1.6.0", + "lodash": "4.17.15", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "react": "^18.2.0" + } +} diff --git a/tests/fixtures/yarn-v1-project/yarn.lock b/tests/fixtures/yarn-v1-project/yarn.lock new file mode 100644 index 0000000..c09c4ed --- /dev/null +++ b/tests/fixtures/yarn-v1-project/yarn.lock @@ -0,0 +1,42 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@scope/pkg@^2.1.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@scope/pkg/-/pkg-2.1.0.tgz#fake" + integrity sha512-fake== + +"axios@^1.6.0", "axios@~1.6.0": + version "1.6.0" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.0.tgz#fake" + integrity sha512-fake== + dependencies: + follow-redirects "^1.15.0" + +follow-redirects@^1.15.0: + version "1.15.3" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.3.tgz#fake" + integrity sha512-fake== + +lodash@4.17.15: + version "4.17.15" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#fake" + integrity sha512-fake== + +lodash@^4.17.21: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#fake" + integrity sha512-fake== + +react@^18.2.0: + version "18.2.0" + resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#fake" + integrity sha512-fake== + +"react-dom@^18.2.0": + version "18.2.0" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#fake" + integrity sha512-fake== + dependencies: + react "^18.2.0" diff --git a/tests/yarn.test.ts b/tests/yarn.test.ts new file mode 100644 index 0000000..915cdd0 --- /dev/null +++ b/tests/yarn.test.ts @@ -0,0 +1,239 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { copyFile, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + extractName, + parseYarnLockfile, + splitDescriptors, +} from '../src/parsers/yarn.js'; +import { detectLockfile, scanLockfile } from '../src/parsers/index.js'; +import { PatchstackError } from '../src/types.js'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const fixtures = path.join(here, 'fixtures'); +const v1Project = path.join(fixtures, 'yarn-v1-project'); +const berryProject = path.join(fixtures, 'yarn-berry-project'); + +describe('extractName', () => { + it('parses plain descriptor', () => { + expect(extractName('axios@^1.6.0')).toBe('axios'); + }); + + it('parses quoted scoped descriptor', () => { + expect(extractName('"@scope/pkg@^2.1.0"')).toBe('@scope/pkg'); + }); + + it('parses yarn berry descriptor with protocol', () => { + expect(extractName('"@scope/pkg@npm:2.1.0"')).toBe('@scope/pkg'); + expect(extractName('"axios@npm:^1.6.0"')).toBe('axios'); + }); + + it('returns null for inputs without a separator', () => { + expect(extractName('')).toBeNull(); + expect(extractName('bare-name')).toBeNull(); + expect(extractName('@scope/no-version')).toBeNull(); + }); +}); + +describe('splitDescriptors', () => { + it('splits unquoted descriptors', () => { + expect(splitDescriptors('axios@^1.6.0, axios@~1.6.0')).toEqual([ + 'axios@^1.6.0', + 'axios@~1.6.0', + ]); + }); + + it('splits quoted descriptors and preserves quotes', () => { + expect(splitDescriptors('"axios@^1.6.0", "axios@~1.6.0"')).toEqual([ + '"axios@^1.6.0"', + '"axios@~1.6.0"', + ]); + }); + + it('treats commas inside quoted strings as literal', () => { + expect(splitDescriptors('"weird@>=1, <2", normal@^3')).toEqual([ + '"weird@>=1, <2"', + 'normal@^3', + ]); + }); +}); + +describe('parseYarnLockfile (v1)', () => { + it('extracts every resolved package', async () => { + const entries = await parseYarnLockfile(path.join(v1Project, 'yarn.lock')); + const names = entries.map((e) => `${e.name}@${e.version}`).sort(); + expect(names).toEqual( + [ + '@scope/pkg@2.1.0', + 'axios@1.6.0', + 'follow-redirects@1.15.3', + 'lodash@4.17.15', + 'lodash@4.17.21', + 'react@18.2.0', + 'react-dom@18.2.0', + ].sort(), + ); + }); + + it('preserves both versions of a duplicated package', async () => { + const entries = await parseYarnLockfile(path.join(v1Project, 'yarn.lock')); + const lodashes = entries.filter((e) => e.name === 'lodash'); + expect(lodashes.map((l) => l.version).sort()).toEqual(['4.17.15', '4.17.21']); + }); + + it('marks names from package.json dependencies/devDependencies as direct', async () => { + const entries = await parseYarnLockfile(path.join(v1Project, 'yarn.lock')); + const byName = new Map(entries.map((e) => [e.name, e])); + expect(byName.get('axios')?.direct).toBe(true); + expect(byName.get('@scope/pkg')?.direct).toBe(true); + expect(byName.get('react')?.direct).toBe(true); + expect(byName.get('react-dom')?.direct).toBe(true); + expect(byName.get('follow-redirects')?.direct).toBe(false); + }); + + it('deduplicates multi-descriptor blocks to a single entry', async () => { + const entries = await parseYarnLockfile(path.join(v1Project, 'yarn.lock')); + const axios = entries.filter((e) => e.name === 'axios'); + expect(axios).toHaveLength(1); + expect(axios[0]?.version).toBe('1.6.0'); + }); +}); + +describe('parseYarnLockfile (berry)', () => { + it('parses berry-format blocks and skips __metadata', async () => { + const entries = await parseYarnLockfile(path.join(berryProject, 'yarn.lock')); + const names = entries.map((e) => `${e.name}@${e.version}`).sort(); + expect(names).toEqual( + [ + '@scope/pkg@2.1.0', + 'axios@1.6.0', + 'follow-redirects@1.15.3', + 'react@18.2.0', + 'react-dom@18.2.0', + 'vitest@3.0.0', + ].sort(), + ); + // The `version: 6` inside __metadata must never leak in as a package. + expect(entries.find((e) => e.version === '6')).toBeUndefined(); + }); + + it('marks direct deps from package.json', async () => { + const entries = await parseYarnLockfile(path.join(berryProject, 'yarn.lock')); + const byName = new Map(entries.map((e) => [e.name, e])); + expect(byName.get('axios')?.direct).toBe(true); + expect(byName.get('vitest')?.direct).toBe(true); + expect(byName.get('react')?.direct).toBe(false); + expect(byName.get('follow-redirects')?.direct).toBe(false); + }); +}); + +describe('parseYarnLockfile errors', () => { + it('throws LOCKFILE_NOT_FOUND when the file is missing', async () => { + await expect(parseYarnLockfile('/nonexistent/yarn.lock')).rejects.toBeInstanceOf( + PatchstackError, + ); + }); + + it('throws LOCKFILE_PARSE_ERROR when no package blocks are present', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'patchstack-connect-yarn-empty-')); + try { + const file = path.join(dir, 'yarn.lock'); + await writeFile(file, '# yarn lockfile v1\n'); + await expect(parseYarnLockfile(file)).rejects.toMatchObject({ + code: 'LOCKFILE_PARSE_ERROR', + }); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it('leaves direct unset when package.json is absent', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'patchstack-connect-yarn-no-pkg-')); + try { + await copyFile(path.join(v1Project, 'yarn.lock'), path.join(dir, 'yarn.lock')); + const entries = await parseYarnLockfile(path.join(dir, 'yarn.lock')); + for (const e of entries) { + expect(e.direct).toBe(false); + } + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); + +describe('detectLockfile for yarn', () => { + let cwd: string; + + beforeEach(async () => { + cwd = await mkdtemp(path.join(tmpdir(), 'patchstack-connect-yarn-detect-')); + }); + + afterEach(async () => { + await rm(cwd, { recursive: true, force: true }); + }); + + it('detects yarn.lock and routes to the yarn parser', async () => { + await copyFile(path.join(v1Project, 'yarn.lock'), path.join(cwd, 'yarn.lock')); + const detected = await detectLockfile(cwd); + expect(detected.filename).toBe('yarn.lock'); + expect(detected.strategy).toBe('yarn-lockfile'); + expect(detected.ecosystem).toBe('npm'); + }); + + it('prefers package-lock.json over yarn.lock when both exist', async () => { + await copyFile( + path.join(fixtures, 'package-lock-v3.json'), + path.join(cwd, 'package-lock.json'), + ); + await copyFile(path.join(v1Project, 'yarn.lock'), path.join(cwd, 'yarn.lock')); + const detected = await detectLockfile(cwd); + expect(detected.filename).toBe('package-lock.json'); + }); + + it('prefers pnpm-lock.yaml over yarn.lock when both exist', async () => { + await copyFile( + path.join(fixtures, 'pnpm-lock-v9.yaml'), + path.join(cwd, 'pnpm-lock.yaml'), + ); + await copyFile(path.join(v1Project, 'yarn.lock'), path.join(cwd, 'yarn.lock')); + const detected = await detectLockfile(cwd); + expect(detected.filename).toBe('pnpm-lock.yaml'); + }); +}); + +describe('scanLockfile for yarn', () => { + it('returns an npm-ecosystem manifest from a yarn v1 project', async () => { + const cwd = await mkdtemp(path.join(tmpdir(), 'patchstack-connect-yarn-scan-')); + try { + await copyFile(path.join(v1Project, 'yarn.lock'), path.join(cwd, 'yarn.lock')); + await copyFile( + path.join(v1Project, 'package.json'), + path.join(cwd, 'package.json'), + ); + const manifest = await scanLockfile(cwd); + expect(manifest.ecosystem).toBe('npm'); + expect(manifest.packages.find((p) => p.name === 'axios')).toBeDefined(); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); + + it('returns an npm-ecosystem manifest from a yarn berry project', async () => { + const cwd = await mkdtemp(path.join(tmpdir(), 'patchstack-connect-yarn-scan-berry-')); + try { + await mkdir(cwd, { recursive: true }); + await copyFile(path.join(berryProject, 'yarn.lock'), path.join(cwd, 'yarn.lock')); + await copyFile( + path.join(berryProject, 'package.json'), + path.join(cwd, 'package.json'), + ); + const manifest = await scanLockfile(cwd); + expect(manifest.ecosystem).toBe('npm'); + expect(manifest.packages.find((p) => p.name === 'vitest')).toBeDefined(); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); +});