From cd4886c6c66723e1947f40e3df91974f8988ba01 Mon Sep 17 00:00:00 2001 From: Ash Wu Date: Thu, 22 Oct 2026 22:35:07 +0800 Subject: [PATCH] [build-tools] install the repack package with npm in an isolated sandbox --- .changeset/repack-install-isolated-npm.md | 5 ++ .../src/common/installDependencies.ts | 20 +---- .../steps/functions/__tests__/repack.test.ts | 89 ++++++++++++++++++- .../functions/__tests__/runAgent.test.ts | 2 +- .../build-tools/src/steps/functions/repack.ts | 38 ++++---- .../steps/utils/__tests__/agentRun.test.ts | 1 + .../build-tools/src/steps/utils/agentRun.ts | 20 ++--- .../installIsolatedNpmPackageAsync.test.ts | 89 +++++++++++++++++++ .../utils/__tests__/packageManager.test.ts | 30 ------- .../utils/installIsolatedNpmPackageAsync.ts | 62 +++++++++++++ .../build-tools/src/utils/npmCacheRegistry.ts | 20 +++++ .../build-tools/src/utils/packageManager.ts | 29 ------ 12 files changed, 296 insertions(+), 109 deletions(-) create mode 100644 .changeset/repack-install-isolated-npm.md create mode 100644 packages/build-tools/src/utils/__tests__/installIsolatedNpmPackageAsync.test.ts create mode 100644 packages/build-tools/src/utils/installIsolatedNpmPackageAsync.ts create mode 100644 packages/build-tools/src/utils/npmCacheRegistry.ts diff --git a/.changeset/repack-install-isolated-npm.md b/.changeset/repack-install-isolated-npm.md new file mode 100644 index 0000000000..9e97d320a4 --- /dev/null +++ b/.changeset/repack-install-isolated-npm.md @@ -0,0 +1,5 @@ +--- +'@expo/build-tools': patch +--- + +Install `@expo/repack-app` for the `eas/repack` step with npm in a sandbox that sees only `PATH` and its own `HOME`, so the build machine's npm and Yarn configuration cannot break the install, fetch it through the EAS npm cache when the job uses one, and show the installer output in the build log. The step no longer looks the version up separately before installing. When the npm cache fails, the install retries against the public registry like the project install does; the `eas/run_agent` agent CLI install shares the same helper and gains the same retry. diff --git a/packages/build-tools/src/common/installDependencies.ts b/packages/build-tools/src/common/installDependencies.ts index be41988505..d6d9d7fd39 100644 --- a/packages/build-tools/src/common/installDependencies.ts +++ b/packages/build-tools/src/common/installDependencies.ts @@ -4,6 +4,7 @@ import path from 'path'; import { BuildContext } from '../context'; import { Sentry } from '../sentry'; +import { NpmCacheRegistryInstallError, isNpmCacheInstallFailure } from '../utils/npmCacheRegistry'; import { PackageManager, findPackagerRootDir } from '../utils/packageManager'; import { isUsingModernYarnVersion } from '../utils/project'; @@ -196,10 +197,6 @@ export function resolvePackagerDir(ctx: BuildContext): string { return packagerRunDir; } -function isNpmCacheInstallFailure(err: unknown, npmCacheUrl: string): boolean { - return getErrorOutput(err).includes(npmCacheUrl); -} - function isNpmCacheRegistryErrorLine(line: string, npmCacheUrl: string): boolean { return ( line.includes(npmCacheUrl) && @@ -207,24 +204,9 @@ function isNpmCacheRegistryErrorLine(line: string, npmCacheUrl: string): boolean ); } -function getErrorOutput(err: unknown): string { - if (!(err instanceof Error)) { - return ''; - } - const { stdout, stderr } = err as Error & { stdout?: string; stderr?: string }; - return [stdout, stderr].filter(Boolean).join('\n'); -} - class NpmCacheRegistryNonFatalError extends SystemError { override name = 'NpmCacheRegistryNonFatalError'; constructor() { super('Non-fatal npm cache registry error during dependency install'); } } - -class NpmCacheRegistryInstallError extends SystemError { - override name = 'NpmCacheRegistryInstallError'; - constructor(cause: unknown) { - super('Failed to install dependencies using npm cache registry', { cause }); - } -} diff --git a/packages/build-tools/src/steps/functions/__tests__/repack.test.ts b/packages/build-tools/src/steps/functions/__tests__/repack.test.ts index 963947a507..1e8a17a309 100644 --- a/packages/build-tools/src/steps/functions/__tests__/repack.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/repack.test.ts @@ -1,8 +1,10 @@ import { type bunyan } from '@expo/logger'; +import turtleSpawnAsync from '@expo/turtle-spawn'; import assert from 'node:assert'; import fg from 'fast-glob'; import { vol } from 'memfs'; import path from 'node:path'; +import resolveFrom from 'resolve-from'; import { createGlobalContextMock } from '../../../__tests__/utils/context'; import { createTestAndroidJob, createTestIosJob } from '../../../__tests__/utils/job'; @@ -17,17 +19,100 @@ import { } from '../repack'; jest.mock('fs'); -jest.mock('@expo/spawn-async'); +jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() })); jest.mock('@expo/repack-app'); -jest.mock('resolve-from', () => jest.fn((_, moduleName) => moduleName)); +jest.mock('resolve-from', () => jest.fn(() => '@expo/repack-app')); jest.mock('../../utils/ios/credentials/manager'); describe(createRepackBuildFunction, () => { + beforeEach(() => { + jest.mocked(turtleSpawnAsync).mockImplementation((async (_command: string, args: string[]) => { + const sandbox = args[args.indexOf('--prefix') + 1]; + const packageName = args[args.length - 1].replace(/@[^@/]*$/, ''); + vol.fromJSON({ + [path.join(sandbox, 'node_modules', packageName, 'package.json')]: JSON.stringify({ + version: '2.0.0-next.1', + }), + }); + }) as unknown as typeof turtleSpawnAsync); + }); + afterEach(() => { jest.restoreAllMocks(); vol.reset(); }); + it('installs the repack package with npm in a sandbox whose env is only PATH and HOME, through the npm cache registry', async () => { + const globalCtx = createGlobalContextMock({ + staticContextContent: { + job: {}, + }, + }); + globalCtx.updateEnv({ + PATH: '/usr/local/bin:/usr/bin', + EAS_BUILD_NPM_CACHE_URL: 'https://npm-cache.expo.test', + NPM_TOKEN: 'secret', + }); + const repack = createRepackBuildFunction(); + const repackStep = repack.createBuildStepFromFunctionCall(globalCtx, { + callInputs: { + platform: 'ios', + source_app_path: '/path/to/source_app.ipa', + repack_package: '@scope/fork', + repack_version: 'next', + }, + }); + const logInfo = jest.spyOn(repackStep.ctx.logger, 'info'); + + await repackStep.executeAsync(); + + const [installCall] = jest.mocked(turtleSpawnAsync).mock.calls; + const npmEnv = installCall[2]?.env; + assert(npmEnv); + expect(Object.keys(npmEnv).sort()).toEqual(['HOME', 'PATH']); + expect(npmEnv.PATH).toMatch(/\/usr\/local\/bin:\/usr\/bin$/); + expect(installCall).toEqual([ + 'npm', + [ + 'install', + '--prefix', + npmEnv.HOME, + '--no-save', + '--package-lock=false', + '--no-audit', + '--no-fund', + '--no-update-notifier', + '--registry', + 'https://npm-cache.expo.test', + '@scope/fork@next', + ], + { env: npmEnv, logger: repackStep.ctx.logger }, + ]); + expect(logInfo).toHaveBeenCalledWith('Using repack from: @scope/fork@next (2.0.0-next.1)'); + expect(resolveFrom).toHaveBeenCalledWith(npmEnv.HOME, '@scope/fork'); + }); + + it('installs the repack package from the default registry when the job has no npm cache', async () => { + const globalCtx = createGlobalContextMock({ + staticContextContent: { + job: {}, + }, + }); + globalCtx.updateEnv({ PATH: '/usr/local/bin:/usr/bin' }); + const repack = createRepackBuildFunction(); + const repackStep = repack.createBuildStepFromFunctionCall(globalCtx, { + callInputs: { + platform: 'ios', + source_app_path: '/path/to/source_app.ipa', + }, + }); + + await repackStep.executeAsync(); + + const [installCall] = jest.mocked(turtleSpawnAsync).mock.calls; + expect(installCall[1]).not.toContain('--registry'); + }); + it('should set generated output path for successful repack', async () => { const repack = createRepackBuildFunction(); const repackStep = repack.createBuildStepFromFunctionCall( diff --git a/packages/build-tools/src/steps/functions/__tests__/runAgent.test.ts b/packages/build-tools/src/steps/functions/__tests__/runAgent.test.ts index 763e28c38a..54ac294d1e 100644 --- a/packages/build-tools/src/steps/functions/__tests__/runAgent.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/runAgent.test.ts @@ -103,7 +103,7 @@ describe(createRunAgentBuildFunction, () => { ); const npmEnv = installMock.mock.calls[0][2]!.env!; const agentEnv = spawnMock.mock.calls[0][2].env!; - expect(Object.keys(npmEnv).sort()).toEqual(['HOME', 'LANG', 'PATH', 'TMPDIR']); + expect(Object.keys(npmEnv).sort()).toEqual(['HOME', 'PATH']); expect(Object.keys(agentEnv).sort()).toEqual([ 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC', 'CLAUDE_CODE_OAUTH_TOKEN', diff --git a/packages/build-tools/src/steps/functions/repack.ts b/packages/build-tools/src/steps/functions/repack.ts index 4e887979d3..eb46d5175e 100644 --- a/packages/build-tools/src/steps/functions/repack.ts +++ b/packages/build-tools/src/steps/functions/repack.ts @@ -23,7 +23,7 @@ import resolveFrom from 'resolve-from'; import { COMMON_FASTLANE_ENV } from '../../common/fastlane'; import IosCredentialsManager from '../utils/ios/credentials/manager'; -import { resolvePackageVersionAsync } from '../../utils/packageManager'; +import { installIsolatedNpmPackageAsync } from '../../utils/installIsolatedNpmPackageAsync'; export function createRepackBuildFunction(): BuildFunction { return new BuildFunction({ @@ -121,18 +121,12 @@ export function createRepackBuildFunction(): BuildFunction { : undefined; const jsBundleOnly = (inputs.js_bundle_only.value as boolean | undefined) ?? false; - const resolvedRepackVersion = - (await resolvePackageVersionAsync({ - logger: stepsCtx.logger, - packageName: inputs.repack_package.value as string, - distTag: inputs.repack_version.value as string, - })) ?? (inputs.repack_version.value as string); - stepsCtx.logger.info( - `Using repack from: ${inputs.repack_package.value}@${inputs.repack_version.value} (${resolvedRepackVersion})` - ); const repackApp = await installAndImportRepackAsync({ packageName: inputs.repack_package.value as string, - version: resolvedRepackVersion, + versionSpec: inputs.repack_version.value as string, + pathEnv: env.PATH, + npmCacheUrl: env.EAS_BUILD_NPM_CACHE_URL, + logger: stepsCtx.logger, }); const { repackAppIosAsync, repackAppAndroidAsync } = repackApp; @@ -226,16 +220,28 @@ function createOutputPath({ */ async function installAndImportRepackAsync({ packageName, - version, + versionSpec, + pathEnv, + npmCacheUrl, + logger, }: { packageName: string; - version: string; + versionSpec: string; + pathEnv: string | undefined; + npmCacheUrl: string | undefined; + logger: bunyan; }): Promise { const sandbox = await fs.promises.mkdtemp(path.join(os.tmpdir(), `repack-package-root-`)); - await spawnAsync('yarn', ['add', `${packageName}@${version}`], { - stdio: 'inherit', - cwd: sandbox, + await installIsolatedNpmPackageAsync({ + packageSpec: `${packageName}@${versionSpec}`, + directory: sandbox, + npmCacheUrl, + pathEnv, + logger, }); + const packageJsonPath = path.join(sandbox, 'node_modules', packageName, 'package.json'); + const { version } = JSON.parse(await fs.promises.readFile(packageJsonPath, 'utf8')); + logger.info(`Using repack from: ${packageName}@${versionSpec} (${version})`); return require(resolveFrom(sandbox, packageName)); } diff --git a/packages/build-tools/src/steps/utils/__tests__/agentRun.test.ts b/packages/build-tools/src/steps/utils/__tests__/agentRun.test.ts index b229ab374b..690da7a452 100644 --- a/packages/build-tools/src/steps/utils/__tests__/agentRun.test.ts +++ b/packages/build-tools/src/steps/utils/__tests__/agentRun.test.ts @@ -186,6 +186,7 @@ describe(runAgentAsync, () => { '--package-lock=false', '--no-audit', '--no-fund', + '--no-update-notifier', '--registry', 'https://npm-cache.expo.test', '@anthropic-ai/claude-code@2.1.291', diff --git a/packages/build-tools/src/steps/utils/agentRun.ts b/packages/build-tools/src/steps/utils/agentRun.ts index 805c0bb03c..326066472a 100644 --- a/packages/build-tools/src/steps/utils/agentRun.ts +++ b/packages/build-tools/src/steps/utils/agentRun.ts @@ -1,6 +1,5 @@ import { SystemError, UserError } from '@expo/eas-build-job'; import { type bunyan } from '@expo/logger'; -import turtleSpawn from '@expo/turtle-spawn'; import { spawn } from 'node:child_process'; import { once } from 'node:events'; import fs from 'node:fs/promises'; @@ -13,6 +12,7 @@ import { type AgentCli, type AgentInvocation, type AgentLogMessage } from './age import { CLAUDE_CODE_CLI, prepareClaudeCodeAsync } from './agents/claude'; import { CODEX_CLI, prepareCodexAsync } from './agents/codex'; import { Sentry } from '../../sentry'; +import { installIsolatedNpmPackageAsync } from '../../utils/installIsolatedNpmPackageAsync'; import { killProcessGroup } from '../../utils/processes'; export type AgentKind = 'claude-code' | 'codex'; @@ -76,17 +76,13 @@ export async function runAgentAsync({ const { packageSpec, bin, formatOutputLine } = AGENT_CLIS[agentKind]; logger.info(`Installing ${packageSpec}.`); try { - await turtleSpawn( - 'npm', - [ - 'install', - ...['--prefix', cliDirectory], - ...['--no-save', '--package-lock=false', '--no-audit', '--no-fund'], - ...(npmCacheUrl ? ['--registry', npmCacheUrl] : []), - packageSpec, - ], - { env: minimalEnv, signal } - ); + await installIsolatedNpmPackageAsync({ + packageSpec, + directory: cliDirectory, + npmCacheUrl, + pathEnv, + signal, + }); } catch (error) { signal.throwIfAborted(); throw new SystemError( diff --git a/packages/build-tools/src/utils/__tests__/installIsolatedNpmPackageAsync.test.ts b/packages/build-tools/src/utils/__tests__/installIsolatedNpmPackageAsync.test.ts new file mode 100644 index 0000000000..f7e1fd900a --- /dev/null +++ b/packages/build-tools/src/utils/__tests__/installIsolatedNpmPackageAsync.test.ts @@ -0,0 +1,89 @@ +import spawn from '@expo/turtle-spawn'; + +import { createMockLogger } from '../../__tests__/utils/logger'; +import { Sentry } from '../../sentry'; +import { installIsolatedNpmPackageAsync } from '../installIsolatedNpmPackageAsync'; + +jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() })); +jest.mock('../../sentry', () => ({ Sentry: { capture: jest.fn() } })); + +const spawnMock = jest.mocked(spawn); +const npmCacheUrl = 'https://npm-cache.expo.test'; + +describe(installIsolatedNpmPackageAsync, () => { + it('retries without the npm cache registry when the install fails against it', async () => { + const logger = createMockLogger(); + const cacheFailure = Object.assign(new Error('npm install exited with non-zero code: 1'), { + stderr: `npm error request to ${npmCacheUrl}/@scope%2ffork failed, reason: ECONNREFUSED`, + }); + spawnMock.mockRejectedValueOnce(cacheFailure).mockResolvedValueOnce({} as never); + + await installIsolatedNpmPackageAsync({ + packageSpec: '@scope/fork@next', + directory: '/tmp/sandbox', + npmCacheUrl, + pathEnv: '/usr/bin', + logger, + }); + + expect(spawnMock).toHaveBeenCalledTimes(2); + expect(spawnMock.mock.calls[0][1]).toEqual(expect.arrayContaining(['--registry', npmCacheUrl])); + expect(spawnMock.mock.calls[1][1]).not.toContain('--registry'); + expect(spawnMock.mock.calls[1][2]).toEqual({ + env: { PATH: '/usr/bin', HOME: '/tmp/sandbox' }, + logger, + }); + expect(logger.warn).toHaveBeenCalledWith( + `Failed to install @scope/fork@next using the npm cache registry (${npmCacheUrl}). Retrying without the npm cache registry.` + ); + expect(Sentry.capture).toHaveBeenCalledWith( + expect.objectContaining({ name: 'NpmCacheRegistryInstallError', cause: cacheFailure }), + { level: 'warning', extras: { packageSpec: '@scope/fork@next', npmCacheUrl } } + ); + }); + + it('rethrows an install failure that does not mention the npm cache registry', async () => { + const error = Object.assign(new Error('npm install exited with non-zero code: 1'), { + stderr: 'npm error notarget No matching version found for @scope/fork@next.', + }); + spawnMock.mockRejectedValueOnce(error); + + await expect( + installIsolatedNpmPackageAsync({ + packageSpec: '@scope/fork@next', + directory: '/tmp/sandbox', + npmCacheUrl, + pathEnv: '/usr/bin', + }) + ).rejects.toBe(error); + + expect(spawnMock).toHaveBeenCalledTimes(1); + expect(Sentry.capture).not.toHaveBeenCalled(); + }); + + it('rethrows the cancellation of an install without retrying', async () => { + const controller = new AbortController(); + const cancellation = new Error('cancelled'); + spawnMock.mockImplementationOnce((() => { + controller.abort(cancellation); + return Promise.reject( + Object.assign(new Error('npm install was killed with SIGTERM'), { + stderr: `npm error request to ${npmCacheUrl}/@scope%2ffork failed`, + }) + ); + }) as unknown as typeof spawn); + + await expect( + installIsolatedNpmPackageAsync({ + packageSpec: '@scope/fork@next', + directory: '/tmp/sandbox', + npmCacheUrl, + pathEnv: '/usr/bin', + signal: controller.signal, + }) + ).rejects.toBe(cancellation); + + expect(spawnMock).toHaveBeenCalledTimes(1); + expect(Sentry.capture).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/build-tools/src/utils/__tests__/packageManager.test.ts b/packages/build-tools/src/utils/__tests__/packageManager.test.ts index a790e58ffc..acaa5a3e6c 100644 --- a/packages/build-tools/src/utils/__tests__/packageManager.test.ts +++ b/packages/build-tools/src/utils/__tests__/packageManager.test.ts @@ -1,9 +1,7 @@ import { errors } from '@expo/eas-build-job'; -import { type bunyan } from '@expo/logger'; import fs from 'fs-extra'; import { vol } from 'memfs'; import path from 'path'; -import semver from 'semver'; import { PackageManager, @@ -16,7 +14,6 @@ import { resolvePackageExec, resolvePackageInstall, resolvePackageManager, - resolvePackageVersionAsync, shouldUseFrozenLockfile, } from '../packageManager'; @@ -293,33 +290,6 @@ describe(resolvePackageInstall, () => { }); }); -describe(resolvePackageVersionAsync, () => { - const logger = { - debug: jest.fn(), - info: jest.fn(), - warn: jest.fn(), - error: jest.fn(), - } as unknown as bunyan; - - it('should resolve version from package name and dist tag', async () => { - const version = await resolvePackageVersionAsync({ - logger, - packageName: '@expo/repack-app', - distTag: 'latest', - }); - expect(semver.valid(version)).toBeTruthy(); - }); - - it('should return null if version cannot be resolved', async () => { - const version = await resolvePackageVersionAsync({ - logger, - packageName: '@expo/repack-app', - distTag: 'nonexistent-dist-tag', - }); - expect(version).toBeNull(); - }); -}); - describe(findPackagerRootDir, () => { beforeEach(() => { vol.reset(); diff --git a/packages/build-tools/src/utils/installIsolatedNpmPackageAsync.ts b/packages/build-tools/src/utils/installIsolatedNpmPackageAsync.ts new file mode 100644 index 0000000000..f79e207408 --- /dev/null +++ b/packages/build-tools/src/utils/installIsolatedNpmPackageAsync.ts @@ -0,0 +1,62 @@ +import { type bunyan } from '@expo/logger'; +import spawn from '@expo/turtle-spawn'; + +import { NpmCacheRegistryInstallError, isNpmCacheInstallFailure } from './npmCacheRegistry'; +import { Sentry } from '../sentry'; + +export async function installIsolatedNpmPackageAsync({ + packageSpec, + directory, + npmCacheUrl, + pathEnv, + logger, + signal, +}: { + packageSpec: string; + directory: string; + npmCacheUrl: string | undefined; + pathEnv: string | undefined; + logger?: bunyan; + signal?: AbortSignal; +}): Promise { + const installAsync = async (registryUrl: string | undefined): Promise => { + await spawn( + 'npm', + [ + 'install', + '--prefix', + directory, + '--no-save', + '--package-lock=false', + '--no-audit', + '--no-fund', + '--no-update-notifier', + ...(registryUrl ? ['--registry', registryUrl] : []), + packageSpec, + ], + // npm reads the user config in $HOME from any cwd, so the install directory doubles as HOME. + { env: { PATH: pathEnv, HOME: directory }, logger, signal } + ); + }; + + if (!npmCacheUrl) { + await installAsync(undefined); + return; + } + try { + await installAsync(npmCacheUrl); + } catch (error) { + signal?.throwIfAborted(); + if (!isNpmCacheInstallFailure(error, npmCacheUrl)) { + throw error; + } + logger?.warn( + `Failed to install ${packageSpec} using the npm cache registry (${npmCacheUrl}). Retrying without the npm cache registry.` + ); + Sentry.capture(new NpmCacheRegistryInstallError(error), { + level: 'warning', + extras: { packageSpec, npmCacheUrl }, + }); + await installAsync(undefined); + } +} diff --git a/packages/build-tools/src/utils/npmCacheRegistry.ts b/packages/build-tools/src/utils/npmCacheRegistry.ts new file mode 100644 index 0000000000..72a602fee1 --- /dev/null +++ b/packages/build-tools/src/utils/npmCacheRegistry.ts @@ -0,0 +1,20 @@ +import { SystemError } from '@expo/eas-build-job'; + +export function isNpmCacheInstallFailure(err: unknown, npmCacheUrl: string): boolean { + return getErrorOutput(err).includes(npmCacheUrl); +} + +function getErrorOutput(err: unknown): string { + if (!(err instanceof Error)) { + return ''; + } + const { stdout, stderr } = err as Error & { stdout?: string; stderr?: string }; + return [stdout, stderr].filter(Boolean).join('\n'); +} + +export class NpmCacheRegistryInstallError extends SystemError { + override name = 'NpmCacheRegistryInstallError'; + constructor(cause: unknown) { + super('Failed to install dependencies using npm cache registry', { cause }); + } +} diff --git a/packages/build-tools/src/utils/packageManager.ts b/packages/build-tools/src/utils/packageManager.ts index 822daa80e6..6aac5db3eb 100644 --- a/packages/build-tools/src/utils/packageManager.ts +++ b/packages/build-tools/src/utils/packageManager.ts @@ -1,5 +1,4 @@ import { errors } from '@expo/eas-build-job'; -import { type bunyan } from '@expo/logger'; import * as PackageManagerUtils from '@expo/package-manager'; import { type BuildStepEnv } from '@expo/steps'; import spawnAsync from '@expo/turtle-spawn'; @@ -126,34 +125,6 @@ export function resolvePackageInstall( } } -/** - * Get the version of a package from the dist-tags. - * Returns null if the version cannot be resolved. - */ -export async function resolvePackageVersionAsync({ - logger, - packageName, - distTag, -}: { - logger: bunyan; - packageName: string; - distTag: string; -}): Promise { - try { - const { stdout } = await spawnAsync('npm', ['view', packageName, 'dist-tags', '--json'], { - stdio: 'pipe', - }); - const distTags = JSON.parse(stdout); - if (distTag in distTags) { - return distTags[distTag]; - } - } catch (e: unknown) { - const message = e instanceof Error ? e.message : String(e); - logger.warn(`Unable to resolve version for ${packageName}@${distTag}: ${message}`); - } - return null; -} - export function findPackagerRootDir(currentDir: string): string { return PackageManagerUtils.resolveWorkspaceRoot(currentDir) ?? currentDir; }