diff --git a/src/lib/determinePackageManager.ts b/src/lib/determinePackageManager.ts new file mode 100644 index 00000000..11ed899b --- /dev/null +++ b/src/lib/determinePackageManager.ts @@ -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 +} diff --git a/src/lib/findLockfile.ts b/src/lib/findLockfile.ts new file mode 100644 index 00000000..ef776738 --- /dev/null +++ b/src/lib/findLockfile.ts @@ -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, + 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 +} diff --git a/src/lib/initOptions.ts b/src/lib/initOptions.ts index fa19a7e1..f49ae97b 100644 --- a/src/lib/initOptions.ts +++ b/src/lib/initOptions.ts @@ -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' @@ -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') } @@ -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, } } diff --git a/src/logging.ts b/src/logging.ts index 0f19eb92..c8fd669b 100755 --- a/src/logging.ts +++ b/src/logging.ts @@ -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) } diff --git a/src/package-managers/yarn.ts b/src/package-managers/yarn.ts index 987891be..76b8a4a3 100644 --- a/src/package-managers/yarn.ts +++ b/src/package-managers/yarn.ts @@ -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' @@ -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 @@ -55,7 +58,7 @@ export const setNpmAuthToken = (npmConfig: Index, [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) @@ -63,35 +66,60 @@ export const setNpmAuthToken = (npmConfig: Index, [dep, scoped } } +/** + * 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, + 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 => { - const npmConfig: Index = {} - 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): Index => { + 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 = {} + + /** 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. @@ -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( @@ -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 @@ -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 => @@ -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, @@ -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, diff --git a/test/determinePackageManager.test.ts b/test/determinePackageManager.test.ts new file mode 100644 index 00000000..12bbfb60 --- /dev/null +++ b/test/determinePackageManager.test.ts @@ -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') +}) diff --git a/test/package-managers/yarn/index.test.ts b/test/package-managers/yarn/index.test.ts index 57468062..d2278fc3 100644 --- a/test/package-managers/yarn/index.test.ts +++ b/test/package-managers/yarn/index.test.ts @@ -1,13 +1,16 @@ import path from 'path' -import chai from 'chai' +import chai, { should } from 'chai' import chaiAsPromised from 'chai-as-promised' import * as yarn from '../../../src/package-managers/yarn' import { Index } from '../../../src/types/IndexType' +import { getPathToLookForYarnrc } from '../../../src/package-managers/yarn' chai.should() chai.use(chaiAsPromised) process.env.NCU_TESTS = 'true' +const isWindows = process.platform === 'win32' + // append the local node_modules bin directory to process.env.PATH so local yarn is used during tests const localBin = path.resolve(__dirname.replace('build/', ''), '../../../node_modules/.bin') const localYarnSpawnOptions = { @@ -72,3 +75,34 @@ describe('yarn', function () { }) }) }) + +describe('getPathToLookForLocalYarnrc', () => { + it('returns the correct path when using Yarn workspaces', () => { + /** 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}.`) + } + + const yarnrcPath = getPathToLookForYarnrc( + { + cwd: isWindows ? 'C:\\home\\test-repo\\packages\\package-a' : '/home/test-repo/packages/package-a', + }, + readdirSyncMock, + ) + + should().exist(yarnrcPath) + yarnrcPath!.should.equal(isWindows ? 'C:\\home\\test-repo\\.yarnrc.yml' : '/home/test-repo/.yarnrc.yml') + }) +})