Skip to content

Commit

Permalink
Fix Yarn detection when using workspaces, fix .yarnrc.yml loading (#…
Browse files Browse the repository at this point in the history
…1148)

Co-authored-by: Raine Revere <raine@cybersemics.org>
  • Loading branch information
srmagura and raineorshine authored Jun 30, 2022
1 parent c81980c commit e445722
Show file tree
Hide file tree
Showing 7 changed files with 284 additions and 37 deletions.
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') {
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

0 comments on commit e445722

Please sign in to comment.