diff --git a/packages/cli/src/services/check-parser/package-files/json-source-file.ts b/packages/cli/src/services/check-parser/package-files/json-source-file.ts index 521da653..0b274c58 100644 --- a/packages/cli/src/services/check-parser/package-files/json-source-file.ts +++ b/packages/cli/src/services/check-parser/package-files/json-source-file.ts @@ -26,4 +26,13 @@ export class JsonSourceFile { // Ignore. } } + + static async loadFromFilePath (filePath: string): Promise | undefined> { + const sourceFile = await SourceFile.loadFromFilePath(filePath) + if (!sourceFile) { + return + } + + return JsonSourceFile.loadFromSourceFile(sourceFile) + } } diff --git a/packages/cli/src/services/check-parser/package-files/package-json-file.ts b/packages/cli/src/services/check-parser/package-files/package-json-file.ts index e2fd55e3..cbe299e0 100644 --- a/packages/cli/src/services/check-parser/package-files/package-json-file.ts +++ b/packages/cli/src/services/check-parser/package-files/package-json-file.ts @@ -18,6 +18,7 @@ type Schema = { dependencies?: Record devDependencies?: Record private?: boolean + workspaces?: string[] } export interface EngineSupportResult { @@ -67,6 +68,10 @@ export class PackageJsonFile { return this.jsonFile.data.engines } + public get workspaces () { + return this.jsonFile.data.workspaces + } + supportsEngine (engine: string, version: string): EngineSupportResult { const requirements = this.engines?.[engine] if (requirements === undefined) { @@ -105,6 +110,24 @@ export class PackageJsonFile { return new PackageJsonFile(jsonFile) } + static async loadFromSourceFile (sourceFile: SourceFile): Promise { + const jsonSourceFile = await JsonSourceFile.loadFromSourceFile(sourceFile) + if (!jsonSourceFile) { + return + } + + return PackageJsonFile.loadFromJsonSourceFile(jsonSourceFile) + } + + static async loadFromFilePath (filePath: string): Promise { + const sourceFile = await SourceFile.loadFromFilePath(filePath) + if (!sourceFile) { + return + } + + return PackageJsonFile.loadFromSourceFile(sourceFile) + } + static filePath (dirPath: string) { return path.join(dirPath, PackageJsonFile.FILENAME) } diff --git a/packages/cli/src/services/check-parser/package-files/package-manager.ts b/packages/cli/src/services/check-parser/package-files/package-manager.ts index 89a69719..40235b8c 100644 --- a/packages/cli/src/services/check-parser/package-files/package-manager.ts +++ b/packages/cli/src/services/check-parser/package-files/package-manager.ts @@ -2,6 +2,8 @@ import fs from 'node:fs/promises' import path from 'node:path' import { lineage } from './walk' +import { PackageJsonFile } from './package-json-file' +import { JsonSourceFile } from './json-source-file' export class Runnable { executable: string @@ -38,19 +40,33 @@ export interface PackageManager { get name (): string installCommand (): Runnable execCommand (args: string[]): Runnable + lookupWorkspace (dir: string): Promise } class NotDetectedError extends Error {} +export class Workspace { + root: string + packages: string[] + + constructor (root: string, packages: string[]) { + this.root = root + this.packages = packages + } +} + export abstract class PackageManagerDetector { abstract get name (): string abstract detectUserAgent (userAgent: string): boolean abstract detectRuntime (): boolean abstract get representativeLockfile (): string | undefined + abstract get representativeConfigFile (): string | undefined abstract detectLockfile (dir: string): Promise + abstract detectConfigFile (dir: string): Promise abstract detectExecutable (lookup: PathLookup): Promise abstract installCommand (): Runnable abstract execCommand (args: string[]): Runnable + abstract lookupWorkspace (dir: string): Promise } export class NpmDetector extends PackageManagerDetector implements PackageManager { @@ -70,10 +86,19 @@ export class NpmDetector extends PackageManagerDetector implements PackageManage return 'package-lock.json' } + get representativeConfigFile (): undefined { + return + } + async detectLockfile (dir: string): Promise { return await accessR(path.join(dir, this.representativeLockfile)) } + // eslint-disable-next-line require-await, @typescript-eslint/no-unused-vars + async detectConfigFile (dir: string): Promise { + throw new NotDetectedError() + } + async detectExecutable (lookup: PathLookup): Promise { await lookup.detectPresence('npm') } @@ -85,6 +110,10 @@ export class NpmDetector extends PackageManagerDetector implements PackageManage execCommand (args: string[]): Runnable { return new Runnable('npx', args) } + + async lookupWorkspace (dir: string): Promise { + return await lookupNearestPackageJsonWorkspace(dir) + } } export class CNpmDetector extends PackageManagerDetector implements PackageManager { @@ -104,11 +133,20 @@ export class CNpmDetector extends PackageManagerDetector implements PackageManag return } + get representativeConfigFile (): undefined { + return + } + // eslint-disable-next-line require-await async detectLockfile (): Promise { throw new NotDetectedError() } + // eslint-disable-next-line require-await, @typescript-eslint/no-unused-vars + async detectConfigFile (dir: string): Promise { + throw new NotDetectedError() + } + async detectExecutable (lookup: PathLookup): Promise { await lookup.detectPresence('cnpm') } @@ -120,6 +158,10 @@ export class CNpmDetector extends PackageManagerDetector implements PackageManag execCommand (args: string[]): Runnable { return new Runnable('npx', args) } + + async lookupWorkspace (dir: string): Promise { + return await lookupNearestPackageJsonWorkspace(dir) + } } export class PNpmDetector extends PackageManagerDetector implements PackageManager { @@ -139,10 +181,18 @@ export class PNpmDetector extends PackageManagerDetector implements PackageManag return 'pnpm-lock.yaml' } + get representativeConfigFile (): string { + return 'pnpm-workspace.yaml' + } + async detectLockfile (dir: string): Promise { return await accessR(path.join(dir, this.representativeLockfile)) } + async detectConfigFile (dir: string): Promise { + return await accessR(path.join(dir, this.representativeConfigFile)) + } + async detectExecutable (lookup: PathLookup): Promise { await lookup.detectPresence('pnpm') } @@ -154,6 +204,35 @@ export class PNpmDetector extends PackageManagerDetector implements PackageManag execCommand (args: string[]): Runnable { return new Runnable('pnpm', args) } + + async lookupWorkspace (dir: string): Promise { + const { execa } = await import('execa') + + const result = await execa('pnpm', ['list', '--json', '--only-projects', '--workspace-root'], { + cwd: dir, + }) + + type ListOnlyProjectsOutput = { + path: string + dependencies: Record + }[] + + const output: ListOnlyProjectsOutput = JSON.parse(result.stdout) + if (!Array.isArray(output)) { + throw new Error(`The output of 'pnpm list' was not an array (stdout=${result.stdout}, stderr=${result.stderr})`) + } + + if (output.length !== 1) { + return + } + + const project = output[0] + + return new Workspace( + project.path, + Object.values(project.dependencies).map(dep => dep.path), + ) + } } export class YarnDetector extends PackageManagerDetector implements PackageManager { @@ -173,10 +252,19 @@ export class YarnDetector extends PackageManagerDetector implements PackageManag return 'yarn.lock' } + get representativeConfigFile (): undefined { + return + } + async detectLockfile (dir: string): Promise { return await accessR(path.join(dir, this.representativeLockfile)) } + // eslint-disable-next-line require-await, @typescript-eslint/no-unused-vars + async detectConfigFile (dir: string): Promise { + throw new NotDetectedError() + } + async detectExecutable (lookup: PathLookup): Promise { await lookup.detectPresence('yarn') } @@ -188,6 +276,10 @@ export class YarnDetector extends PackageManagerDetector implements PackageManag execCommand (args: string[]): Runnable { return new Runnable('yarn', args) } + + async lookupWorkspace (dir: string): Promise { + return await lookupNearestPackageJsonWorkspace(dir) + } } export class DenoDetector extends PackageManagerDetector implements PackageManager { @@ -207,10 +299,18 @@ export class DenoDetector extends PackageManagerDetector implements PackageManag return 'deno.lock' } + get representativeConfigFile (): string { + return 'deno.json' + } + async detectLockfile (dir: string): Promise { return await accessR(path.join(dir, this.representativeLockfile)) } + async detectConfigFile (dir: string): Promise { + return await accessR(path.join(dir, this.representativeConfigFile)) + } + async detectExecutable (lookup: PathLookup): Promise { await lookup.detectPresence('deno') } @@ -222,6 +322,36 @@ export class DenoDetector extends PackageManagerDetector implements PackageManag execCommand (args: string[]): Runnable { return new Runnable('deno', ['run', '-A', `npm:${args[0]}`, ...args.slice(1)]) } + + async lookupWorkspace (dir: string): Promise { + for (const searchPath of lineage(dir)) { + try { + const configFile = await this.detectConfigFile(searchPath) + + type Schema = { + workspace?: string[] + } + + const jsonFile = await JsonSourceFile.loadFromFilePath(configFile) + if (!jsonFile) { + continue + } + + const workspaces = jsonFile.data.workspace + if (!workspaces) { + continue + } + + const packages = workspaces.map(packagePath => { + return path.resolve(searchPath, packagePath) + }) + + return new Workspace(searchPath, packages) + } catch { + continue + } + } + } } export class BunDetector extends PackageManagerDetector implements PackageManager { @@ -241,10 +371,19 @@ export class BunDetector extends PackageManagerDetector implements PackageManage return 'bun.lockb' } + get representativeConfigFile (): undefined { + return + } + async detectLockfile (dir: string): Promise { return await accessR(path.join(dir, this.representativeLockfile)) } + // eslint-disable-next-line require-await, @typescript-eslint/no-unused-vars + async detectConfigFile (dir: string): Promise { + throw new NotDetectedError() + } + async detectExecutable (lookup: PathLookup): Promise { await lookup.detectPresence('bun') } @@ -256,6 +395,10 @@ export class BunDetector extends PackageManagerDetector implements PackageManage execCommand (args: string[]): Runnable { return new Runnable('bunx', args) } + + async lookupWorkspace (dir: string): Promise { + return await lookupNearestPackageJsonWorkspace(dir) + } } async function accessR (filePath: string): Promise { @@ -402,6 +545,18 @@ export async function detectPackageManager ( // Nothing detected. } + // Next, try to find a config file. + try { + const { packageManager } = await detectNearestConfigFile(dir, { + detectors, + root: options?.root, + }) + + return packageManager + } catch { + // Nothing detected. + } + // Finally, try to find a relevant executable. // // This can generate a whole bunch of path lookups. Try one by one despite @@ -477,3 +632,80 @@ export async function detectNearestLockfile ( throw new NoLockfileFoundError(searchPaths, lockfiles) } + +export interface NearestConfigFile { + packageManager: PackageManager + configFile: string +} + +export class NoConfigFileFoundError extends Error { + searchPaths: string[] + configFiles: string[] + + constructor (searchPaths: string[], configFiles: string[], options?: ErrorOptions) { + const message = `Unable to detect a config file in any of the following paths:` + + `\n\n` + + `${searchPaths.map(searchPath => ` ${searchPath}`).join('\n')}` + + `\n\n` + + `Config files we looked for:` + + `\n\n` + + `${configFiles.map(lockfile => ` ${lockfile}`).join('\n')}` + super(message, options) + this.name = 'NoConfigFileFoundError' + this.searchPaths = searchPaths + this.configFiles = configFiles + } +} + +export async function detectNearestConfigFile ( + dir: string, + options?: DetectOptions, +): Promise { + const detectors = options?.detectors ?? knownPackageManagers + + const searchPaths: string[] = [] + + for (const searchPath of lineage(dir, { root: options?.root })) { + try { + searchPaths.push(searchPath) + + // Assume that only a single kind of config file exists, which means + // the resolve order does not matter. + return await Promise.any(detectors.map(async detector => { + const configFile = await detector.detectConfigFile(searchPath) + return { + packageManager: detector, + configFile, + } + })) + } catch { + // Nothing detected. + } + } + + const configFiles = detectors.reduce((acc, detector) => { + return acc.concat(detector.representativeConfigFile ?? []) + }, []) + + throw new NoConfigFileFoundError(searchPaths, configFiles) +} + +async function lookupNearestPackageJsonWorkspace (dir: string): Promise { + for (const searchPath of lineage(dir)) { + const packageJson = await PackageJsonFile.loadFromFilePath(PackageJsonFile.filePath(searchPath)) + if (!packageJson) { + continue + } + + const workspaces = packageJson.workspaces + if (!workspaces) { + continue + } + + const packages = workspaces.map(packagePath => { + return path.resolve(searchPath, packagePath) + }) + + return new Workspace(searchPath, packages) + } +}