Skip to content

Commit 25e1f67

Browse files
committed
Add root-known package manager helper
1 parent 9bdcacc commit 25e1f67

5 files changed

Lines changed: 111 additions & 16 deletions

File tree

packages/app/src/cli/models/project/project-integration.test.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {loadLocalExtensionsSpecifications} from '../extensions/load-specificatio
66
import {handleWatcherEvents} from '../../services/dev/app-events/app-event-watcher-handler.js'
77
import {EventType} from '../../services/dev/app-events/app-event-watcher.js'
88
import {describe, expect, test} from 'vitest'
9-
import {inTemporaryDirectory, writeFile, mkdir} from '@shopify/cli-kit/node/fs'
9+
import {inTemporaryDirectory, writeFile, mkdir, removeFile} from '@shopify/cli-kit/node/fs'
1010
import {joinPath} from '@shopify/cli-kit/node/path'
1111
import {AbortController} from '@shopify/cli-kit/node/abort'
1212

@@ -199,6 +199,19 @@ describe('Project integration', () => {
199199
})
200200
})
201201

202+
test('Project uses unknown package manager metadata when the app root has no package.json', async () => {
203+
await inTemporaryDirectory(async (dir) => {
204+
await setupRealApp(dir)
205+
await removeFile(joinPath(dir, 'package.json'))
206+
207+
const project = await Project.load(dir)
208+
209+
expect(project.packageManager).toBe('unknown')
210+
expect(project.nodeDependencies).toStrictEqual({})
211+
expect(project.usesWorkspaces).toBe(false)
212+
})
213+
})
214+
202215
test('multi-config project discovers all configs', async () => {
203216
await inTemporaryDirectory(async (dir) => {
204217
await setupRealApp(dir)

packages/app/src/cli/models/project/project.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import {readAndParseDotEnv, DotEnvFile} from '@shopify/cli-kit/node/dot-env'
44
import {fileExists, glob, findPathUp, readFile} from '@shopify/cli-kit/node/fs'
55
import {
66
getDependencies,
7-
getPackageManager,
8-
PackageManager,
7+
getPackageManagerForProjectRoot,
8+
ProjectPackageManager,
99
usesWorkspaces as detectUsesWorkspaces,
1010
} from '@shopify/cli-kit/node/node-package-manager'
1111
import {joinPath, basename} from '@shopify/cli-kit/node/path'
@@ -76,7 +76,7 @@ export class Project {
7676
// Project metadata
7777
const packageJSONPath = joinPath(directory, 'package.json')
7878
const hasPackageJson = await fileExists(packageJSONPath)
79-
const packageManager = hasPackageJson ? await getPackageManager(directory) : 'unknown'
79+
const packageManager = hasPackageJson ? await getPackageManagerForProjectRoot(directory) : 'unknown'
8080
const nodeDependencies = hasPackageJson ? await getDependencies(packageJSONPath) : {}
8181
const usesWorkspaces = hasPackageJson ? await detectUsesWorkspaces(directory) : false
8282

@@ -101,7 +101,7 @@ export class Project {
101101
}
102102

103103
readonly directory: string
104-
readonly packageManager: PackageManager
104+
readonly packageManager: ProjectPackageManager | 'unknown'
105105
readonly nodeDependencies: Record<string, string>
106106
readonly usesWorkspaces: boolean
107107
readonly appConfigFiles: TomlFile[]
@@ -119,7 +119,7 @@ export class Project {
119119

120120
private constructor(options: {
121121
directory: string
122-
packageManager: PackageManager
122+
packageManager: ProjectPackageManager | 'unknown'
123123
nodeDependencies: Record<string, string>
124124
usesWorkspaces: boolean
125125
appConfigFiles: TomlFile[]

packages/cli-kit/src/public/node/node-package-manager.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
addResolutionOrOverride,
1313
writePackageJSON,
1414
getPackageManager,
15+
getPackageManagerForProjectRoot,
1516
packageManagerBinaryCommandForDirectory,
1617
installNPMDependenciesRecursively,
1718
addNPMDependencies,
@@ -784,6 +785,23 @@ describe('addResolutionOrOverride', () => {
784785
})
785786
})
786787

788+
test('when package.json has no lockfile then npm overrides are used by default', async () => {
789+
await inTemporaryDirectory(async (tmpDir) => {
790+
// Given
791+
const reactType = {'@types/react': '17.0.30'}
792+
const packageJsonPath = joinPath(tmpDir, 'package.json')
793+
await writeFile(packageJsonPath, JSON.stringify({}))
794+
795+
// When
796+
await addResolutionOrOverride(tmpDir, reactType)
797+
798+
// Then
799+
const packageJsonContent = await readAndParsePackageJson(packageJsonPath)
800+
expect(packageJsonContent.overrides).toEqual(reactType)
801+
expect(packageJsonContent.resolutions).toBeUndefined()
802+
})
803+
})
804+
787805
test('when package.json with existing resolution type and yarn manager then dependency version is overwritten', async () => {
788806
await inTemporaryDirectory(async (tmpDir) => {
789807
// Given
@@ -951,6 +969,43 @@ describe('getPackageManager', () => {
951969
})
952970
})
953971

972+
describe('getPackageManagerForProjectRoot', () => {
973+
test('detects the package manager from root markers without consulting the user agent', async () => {
974+
await inTemporaryDirectory(async (tmpDir) => {
975+
await writePackageJSON(tmpDir, {name: 'mock name'})
976+
await writeFile(joinPath(tmpDir, 'yarn.lock'), '')
977+
vi.stubEnv('npm_config_user_agent', 'pnpm/9.0.0')
978+
979+
try {
980+
await expect(getPackageManagerForProjectRoot(tmpDir)).resolves.toBe('yarn')
981+
} finally {
982+
vi.unstubAllEnvs()
983+
}
984+
})
985+
})
986+
987+
test('defaults to npm when the project root has a package.json but no lockfile markers', async () => {
988+
await inTemporaryDirectory(async (tmpDir) => {
989+
await writePackageJSON(tmpDir, {name: 'mock name'})
990+
vi.stubEnv('npm_config_user_agent', 'pnpm/9.0.0')
991+
992+
try {
993+
await expect(getPackageManagerForProjectRoot(tmpDir)).resolves.toBe('npm')
994+
} finally {
995+
vi.unstubAllEnvs()
996+
}
997+
})
998+
})
999+
1000+
test('throws when the provided root does not contain a package.json', async () => {
1001+
await inTemporaryDirectory(async (tmpDir) => {
1002+
await expect(getPackageManagerForProjectRoot(tmpDir)).rejects.toThrow(
1003+
new PackageJsonNotFoundError(normalizePath(tmpDir)),
1004+
)
1005+
})
1006+
})
1007+
})
1008+
9541009
describe('packageManagerBinaryCommandForDirectory', () => {
9551010
test('uses npm exec with -- for npm', async () => {
9561011
await inTemporaryDirectory(async (tmpDir) => {

packages/cli-kit/src/public/node/node-package-manager.ts

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ export type DependencyType = 'dev' | 'prod' | 'peer'
5757
*/
5858
export const packageManager = ['yarn', 'npm', 'pnpm', 'bun', 'homebrew', 'unknown'] as const
5959
export type PackageManager = (typeof packageManager)[number]
60-
type ProjectPackageManager = Extract<PackageManager, 'yarn' | 'npm' | 'pnpm' | 'bun'>
60+
export type ProjectPackageManager = Extract<PackageManager, 'yarn' | 'npm' | 'pnpm' | 'bun'>
6161

6262
/**
6363
* Returns an abort error that's thrown when the package manager can't be determined.
@@ -145,6 +145,17 @@ function packageManagerBinaryCommand(
145145
}
146146
}
147147

148+
function getProjectPackageManagerAtDirectory(directory: string): ProjectPackageManager | undefined {
149+
if (fileExistsSync(joinPath(directory, yarnLockfile))) return 'yarn'
150+
if (fileExistsSync(joinPath(directory, pnpmLockfile)) || fileExistsSync(joinPath(directory, pnpmWorkspaceFile))) {
151+
return 'pnpm'
152+
}
153+
if (hasBunLockfileSync(directory)) return 'bun'
154+
if (fileExistsSync(joinPath(directory, npmLockfile))) return 'npm'
155+
156+
return undefined
157+
}
158+
148159
/**
149160
* Returns the dependency manager used in a directory.
150161
* Walks upward from `fromDirectory` so workspace packages (e.g. `extensions/my-fn/package.json`)
@@ -158,12 +169,9 @@ export async function getPackageManager(fromDirectory: string): Promise<PackageM
158169
let current = fromDirectory
159170
outputDebug(outputContent`Looking for a lockfile in ${outputToken.path(current)}...`)
160171
while (true) {
161-
if (fileExistsSync(joinPath(current, yarnLockfile))) return 'yarn'
162-
if (fileExistsSync(joinPath(current, pnpmLockfile)) || fileExistsSync(joinPath(current, pnpmWorkspaceFile))) {
163-
return 'pnpm'
164-
}
165-
if (hasBunLockfileSync(current)) return 'bun'
166-
if (fileExistsSync(joinPath(current, npmLockfile))) return 'npm'
172+
const detectedPackageManager = getProjectPackageManagerAtDirectory(current)
173+
if (detectedPackageManager) return detectedPackageManager
174+
167175
const parent = dirname(current)
168176
if (parent === current) break
169177
current = parent
@@ -175,6 +183,25 @@ export async function getPackageManager(fromDirectory: string): Promise<PackageM
175183
return 'npm'
176184
}
177185

186+
/**
187+
* Resolves the package manager for a directory already known to be the project root.
188+
*
189+
* Use this when the caller already knows the root directory and wants to inspect that root
190+
* directly rather than walking upward from a child directory.
191+
*
192+
* @param rootDirectory - The known project root directory.
193+
* @returns The package manager detected from root markers, defaulting to `npm` when no marker exists.
194+
* @throws PackageJsonNotFoundError if the provided directory does not contain a package.json.
195+
*/
196+
export async function getPackageManagerForProjectRoot(rootDirectory: string): Promise<ProjectPackageManager> {
197+
const packageJsonPath = joinPath(rootDirectory, 'package.json')
198+
if (!(await fileExists(packageJsonPath))) {
199+
throw new PackageJsonNotFoundError(rootDirectory)
200+
}
201+
202+
return getProjectPackageManagerAtDirectory(rootDirectory) ?? 'npm'
203+
}
204+
178205
/**
179206
* Builds the command and argv needed to execute a local binary using the package manager
180207
* detected from the provided directory or its ancestors.
@@ -733,7 +760,7 @@ export async function findUpAndReadPackageJson(fromDirectory: string): Promise<{
733760
}
734761

735762
export async function addResolutionOrOverride(directory: string, dependencies: Record<string, string>): Promise<void> {
736-
const packageManager = await getPackageManager(directory)
763+
const packageManager = await getPackageManagerForProjectRoot(directory)
737764
const packageJsonPath = joinPath(directory, 'package.json')
738765
const packageJsonContent = await readAndParsePackageJson(packageJsonPath)
739766

packages/cli-kit/src/public/node/upgrade.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
DependencyType,
99
usesWorkspaces,
1010
addNPMDependencies,
11-
getPackageManager,
11+
getPackageManagerForProjectRoot,
1212
} from './node-package-manager.js'
1313
import {outputContent, outputDebug, outputInfo, outputToken, outputWarn} from './output.js'
1414
import {cwd, moduleDirectory, sniffForPath} from './path.js'
@@ -210,7 +210,7 @@ async function installJsonDependencies(
210210

211211
if (packagesToUpdate.length > 0) {
212212
await addNPMDependencies(packagesToUpdate, {
213-
packageManager: await getPackageManager(directory),
213+
packageManager: await getPackageManagerForProjectRoot(directory),
214214
type: depsEnv,
215215
directory,
216216
stdout: process.stdout,

0 commit comments

Comments
 (0)