Skip to content
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

Fix Yarn detection when using workspaces, fix .yarnrc.yml loading #1148

Merged
merged 8 commits into from
Jun 30, 2022
26 changes: 26 additions & 0 deletions src/lib/determinePackageManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import fs from 'fs'
import { Options } from '../types/Options'
import findLockfile from './findLockfile'

const defaultPackageManager = 'npm'

/**
* If the packageManager option was not provided, look at the lockfiles to
* determine which package manager is being used.
*
* @param readdirSync This is only a parameter so that it can be used in tests.
*/
export default function determinePackageManager(
options: Options,
readdirSync: (_path: string) => string[] = fs.readdirSync,
): string {
if (options.packageManager) return options.packageManager
if (options.global) return defaultPackageManager

const lockfileName = findLockfile(options, readdirSync)?.filename

if (lockfileName === 'package-lock.json') return 'npm'
if (lockfileName === 'yarn.lock') return 'yarn'

return defaultPackageManager
}
44 changes: 44 additions & 0 deletions src/lib/findLockfile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import fs from 'fs'
import path from 'path'
import { Options } from '../types/Options'

/**
* Goes up the filesystem tree until it finds a package-lock.json or yarn.lock.
*
* @param readdirSync This is only a parameter so that it can be used in tests.
* @returns The path of the directory that contains the lockfile and the
* filename of the lockfile.
*/
export default function findLockfile(
options: Pick<Options, 'cwd' | 'packageFile'>,
readdirSync: (_path: string) => string[] = fs.readdirSync,
): { directoryPath: string; filename: string } | null {
try {
// 1. explicit cwd
// 2. same directory as package file
// 3. current directory
let currentPath = options.cwd ? options.cwd : options.packageFile ? path.dirname(options.packageFile) : '.'

// eslint-disable-next-line fp/no-loops
while (true) {
const files = readdirSync(currentPath)

if (files.includes('package-lock.json')) {
return { directoryPath: currentPath, filename: 'package-lock.json' }
}

if (files.includes('yarn.lock')) {
return { directoryPath: currentPath, filename: 'yarn.lock' }
}

const pathParent = path.resolve(currentPath, '..')
if (pathParent === currentPath) break

currentPath = pathParent
}
} catch (e) {
// if readdirSync fails, return null
}

return null
}
12 changes: 6 additions & 6 deletions src/lib/initOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import Chalk from 'chalk'
import cliOptions from '../cli-options'
import programError from './programError'
import getPackageFileName from './getPackageFileName'
import determinePackageManager from './determinePackageManager'
import { print } from '../logging'
import { Options } from '../types/Options'
import { RunOptions } from '../types/RunOptions'
Expand Down Expand Up @@ -115,11 +116,10 @@ function initOptions(runOptions: RunOptions, { cli }: { cli?: boolean } = {}): O

const format = options.format || []

// autodetect yarn
const files = fs.readdirSync(options.cwd || '.')
const autoYarn =
!options.packageManager && !options.global && files.includes('yarn.lock') && !files.includes('package-lock.json')
if (autoYarn) {
const packageManager = determinePackageManager(options)

// only print 'Using yarn' when autodetected
if (!options.packageManager && packageManager === 'yarn') {
srmagura marked this conversation as resolved.
Show resolved Hide resolved
print(options, 'Using yarn')
}

Expand All @@ -138,7 +138,7 @@ function initOptions(runOptions: RunOptions, { cli }: { cli?: boolean } = {}): O
target,
// imply upgrade in interactive mode when json is not specified as the output
...(options.interactive && options.upgrade === undefined ? { upgrade: !json } : null),
...(!options.packageManager && { packageManager: autoYarn ? 'yarn' : 'npm' }),
packageManager,
}
}

Expand Down
3 changes: 2 additions & 1 deletion src/logging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ export function print(
if (
!options.json &&
options.loglevel !== 'silent' &&
(loglevel == null || logLevels[options.loglevel as unknown as keyof typeof logLevels] >= logLevels[loglevel])
(loglevel == null ||
logLevels[(options.loglevel ?? 'warn') as unknown as keyof typeof logLevels] >= logLevels[loglevel])
) {
console[method](message)
}
Expand Down
104 changes: 75 additions & 29 deletions src/package-managers/yarn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { once, EventEmitter } from 'events'
import _ from 'lodash'
import cint from 'cint'
import fs from 'fs'
import os from 'os'
import path from 'path'
import jsonlines from 'jsonlines'
import memoize from 'fast-memoize'
import spawn from 'spawn-please'
Expand All @@ -19,6 +21,7 @@ import { SpawnOptions } from '../types/SpawnOptions'
import { Version } from '../types/Version'
import { NpmOptions } from '../types/NpmOptions'
import { allowDeprecatedOrIsNotDeprecated, allowPreOrIsNotPre, satisfiesNodeEngine } from './filters'
import findLockfile from '../lib/findLockfile'

interface ParsedDep {
version: string
Expand Down Expand Up @@ -55,43 +58,68 @@ export const setNpmAuthToken = (npmConfig: Index<string | boolean>, [dep, scoped
let trimmedRegistryServer = registryServer.replace(/^https?:/, '')

if (trimmedRegistryServer.endsWith('/')) {
trimmedRegistryServer = trimmedRegistryServer.substring(0, trimmedRegistryServer.length - 1)
trimmedRegistryServer = trimmedRegistryServer.slice(0, -1)
}

npmConfig[`${trimmedRegistryServer}/:_authToken`] = interpolate(scopedConfig.npmAuthToken, process.env)
}
}
}

/**
* Returns the path to the local .yarnrc.yml, or undefined. This doesn't
* actually check that the .yarnrc.yml file exists.
*
* Exported for test purposes only.
*
* @param readdirSync This is only a parameter so that it can be used in tests.
*/
export function getPathToLookForYarnrc(
options: Pick<Options, 'global' | 'cwd' | 'packageFile'>,
readdirSync: (_path: string) => string[] = fs.readdirSync,
): string | undefined {
if (options.global) return undefined

const directoryPath = findLockfile(options, readdirSync)?.directoryPath
if (!directoryPath) return undefined

return path.join(directoryPath, '.yarnrc.yml')
}

// If private registry auth is specified in npmScopes in .yarnrc.yml, read them in and convert them to npm config variables.
// Define as a memoized function to efficiently call existsSync and readFileSync only once, and only if yarn is being used.
// https://github.com/raineorshine/npm-check-updates/issues/1036
const npmConfigFromYarn = memoize((): Index<string | boolean> => {
const npmConfig: Index<string | boolean> = {}
const yarnrcLocalExists = fs.existsSync('.yarnrc.yml')
const yarnrcUserExists = fs.existsSync('~/.yarnrc.yml')
const yarnrcLocal = yarnrcLocalExists ? fs.readFileSync('.yarnrc.yml', 'utf-8') : ''
const yarnrcUser = yarnrcUserExists ? fs.readFileSync('~/.yarnrc.yml', 'utf-8') : ''
const yarnConfigLocal: YarnConfig = yaml.parse(yarnrcLocal)
const yarnConfigUser: YarnConfig = yaml.parse(yarnrcUser)

/** Reads a registry from a yarn config. interpolates it, and sets it on the npm config. */
const setNpmRegistry = ([dep, scopedConfig]: [string, NpmScope]) => {
if (scopedConfig.npmRegistryServer) {
npmConfig[`@${dep}:registry`] = scopedConfig.npmRegistryServer
const npmConfigFromYarn = memoize(
(options: Pick<Options, 'global' | 'cwd' | 'packageFile'>): Index<string | boolean> => {
const yarnrcLocalPath = getPathToLookForYarnrc(options)
const yarnrcUserPath = path.join(os.homedir(), '.yarnrc.yml')
const yarnrcLocalExists = typeof yarnrcLocalPath === 'string' && fs.existsSync(yarnrcLocalPath)
const yarnrcUserExists = fs.existsSync(yarnrcUserPath)
const yarnrcLocal = yarnrcLocalExists ? fs.readFileSync(yarnrcLocalPath, 'utf-8') : ''
const yarnrcUser = yarnrcUserExists ? fs.readFileSync(yarnrcUserPath, 'utf-8') : ''
const yarnConfigLocal: YarnConfig = yaml.parse(yarnrcLocal)
const yarnConfigUser: YarnConfig = yaml.parse(yarnrcUser)

const npmConfig: Index<string | boolean> = {}

/** Reads a registry from a yarn config. interpolates it, and sets it on the npm config. */
const setNpmRegistry = ([dep, scopedConfig]: [string, NpmScope]) => {
if (scopedConfig.npmRegistryServer) {
npmConfig[`@${dep}:registry`] = scopedConfig.npmRegistryServer
}
}
}

// set registry for all npm scopes
Object.entries(yarnConfigUser?.npmScopes || {}).forEach(setNpmRegistry)
Object.entries(yarnConfigLocal?.npmScopes || {}).forEach(setNpmRegistry)
// set registry for all npm scopes
Object.entries(yarnConfigUser?.npmScopes || {}).forEach(setNpmRegistry)
Object.entries(yarnConfigLocal?.npmScopes || {}).forEach(setNpmRegistry)

// set auth token after npm registry, since auth token syntax uses regitry
Object.entries(yarnConfigUser?.npmScopes || {}).forEach(s => setNpmAuthToken(npmConfig, s))
Object.entries(yarnConfigLocal?.npmScopes || {}).forEach(s => setNpmAuthToken(npmConfig, s))
// set auth token after npm registry, since auth token syntax uses regitry
Object.entries(yarnConfigUser?.npmScopes || {}).forEach(s => setNpmAuthToken(npmConfig, s))
Object.entries(yarnConfigLocal?.npmScopes || {}).forEach(s => setNpmAuthToken(npmConfig, s))

return npmConfig
})
return npmConfig
},
)

/**
* Parse JSON lines and throw an informative error on failure.
Expand Down Expand Up @@ -230,8 +258,14 @@ export const list = async (options: Options = {}, spawnOptions?: SpawnOptions) =
* @param options
* @returns
*/
export const greatest: GetVersion = async (packageName, currentVersion, options = {}) => {
const versions = (await viewOne(packageName, 'versions', currentVersion, options, npmConfigFromYarn())) as Packument[]
export const greatest: GetVersion = async (packageName, currentVersion, options: Options = {}) => {
const versions = (await viewOne(
packageName,
'versions',
currentVersion,
options,
npmConfigFromYarn(options),
)) as Packument[]

return (
_.last(
Expand Down Expand Up @@ -259,7 +293,7 @@ export const distTag: GetVersion = async (packageName, currentVersion, options:
timeout: options.timeout,
retry: options.retry,
},
npmConfigFromYarn(),
npmConfigFromYarn(options),
)) as unknown as Packument // known type based on dist-tags.latest

// latest should not be deprecated
Expand Down Expand Up @@ -304,7 +338,7 @@ export const newest: GetVersion = async (packageName: string, currentVersion, op
currentVersion,
options,
0,
npmConfigFromYarn(),
npmConfigFromYarn(options),
)

const versionsSatisfyingNodeEngine = _.filter(result.versions, version =>
Expand Down Expand Up @@ -333,7 +367,13 @@ export const newest: GetVersion = async (packageName: string, currentVersion, op
* @returns
*/
export const minor: GetVersion = async (packageName, currentVersion, options = {}) => {
const versions = (await viewOne(packageName, 'versions', currentVersion, options, npmConfigFromYarn())) as Packument[]
const versions = (await viewOne(
packageName,
'versions',
currentVersion,
options,
npmConfigFromYarn(options),
)) as Packument[]
return versionUtil.findGreatestByLevel(
_.filter(versions, filterPredicate(options)).map(o => o.version),
currentVersion,
Expand All @@ -350,7 +390,13 @@ export const minor: GetVersion = async (packageName, currentVersion, options = {
* @returns
*/
export const patch: GetVersion = async (packageName, currentVersion, options = {}) => {
const versions = (await viewOne(packageName, 'versions', currentVersion, options, npmConfigFromYarn())) as Packument[]
const versions = (await viewOne(
packageName,
'versions',
currentVersion,
options,
npmConfigFromYarn(options),
)) as Packument[]
return versionUtil.findGreatestByLevel(
_.filter(versions, filterPredicate(options)).map(o => o.version),
currentVersion,
Expand Down
96 changes: 96 additions & 0 deletions test/determinePackageManager.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import chai from 'chai'
import determinePackageManager from '../src/lib/determinePackageManager'

chai.should()

const isWindows = process.platform === 'win32'

it('returns options.packageManager if set', () => {
determinePackageManager({ packageManager: 'fake' }).should.equal('fake')
})

it('returns yarn if yarn.lock exists in cwd', () => {
/** Mock for filesystem calls. */
function readdirSyncMock(path: string): string[] {
switch (path) {
case '/home/test-repo':
case 'C:\\home\\test-repo':
return ['yarn.lock']
}

throw new Error(`Mock cannot handle path: ${path}.`)
}

determinePackageManager(
{
cwd: isWindows ? 'C:\\home\\test-repo' : '/home/test-repo',
},
readdirSyncMock,
).should.equal('yarn')
})

it('returns yarn if yarn.lock exists in an ancestor directory', () => {
/** Mock for filesystem calls. */
function readdirSyncMock(path: string): string[] {
switch (path) {
case '/home/test-repo/packages/package-a':
case 'C:\\home\\test-repo\\packages\\package-a':
return ['index.ts']
case '/home/test-repo/packages':
case 'C:\\home\\test-repo\\packages':
return []
case '/home/test-repo':
case 'C:\\home\\test-repo':
return ['yarn.lock']
}

throw new Error(`Mock cannot handle path: ${path}.`)
}

determinePackageManager(
{
cwd: isWindows ? 'C:\\home\\test-repo\\packages\\package-a' : '/home/test-repo/packages/package-a',
},
readdirSyncMock,
).should.equal('yarn')
})

it('returns npm if package-lock.json found before yarn.lock', () => {
/** Mock for filesystem calls. */
function readdirSyncMock(path: string): string[] {
switch (path) {
case '/home/test-repo/packages/package-a':
case 'C:\\home\\test-repo\\packages\\package-a':
return ['index.ts']
case '/home/test-repo/packages':
case 'C:\\home\\test-repo\\packages':
return ['package-lock.json']
case '/home/test-repo':
case 'C:\\home\\test-repo':
return ['yarn.lock']
}

throw new Error(`Mock cannot handle path: ${path}.`)
}

determinePackageManager(
{
cwd: isWindows ? 'C:\\home\\test-repo\\packages\\package-a' : '/home/test-repo/packages/package-a',
},
readdirSyncMock,
).should.equal('npm')
})

it('does not loop infinitely if no lockfile found', () => {
/** Mock for filesystem calls. */
function readdirSyncMock(): string[] {
return []
}

determinePackageManager(
{
cwd: isWindows ? 'C:\\home\\test-repo\\packages\\package-a' : '/home/test-repo/packages/package-a',
},
readdirSyncMock,
).should.equal('npm')
})
Loading