Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 58 additions & 2 deletions packages/cli-kit/src/public/node/is-global.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import {currentProcessIsGlobal, inferPackageManagerForGlobalCLI, installGlobalCLIPrompt} from './is-global.js'
import {
currentProcessIsGlobal,
inferPackageManagerForGlobalCLI,
installGlobalCLIPrompt,
getWorkspaceRoot,
} from './is-global.js'
import {findPathUpSync} from './fs.js'
import {cwd} from './path.js'
import {terminalSupportsPrompting} from './system.js'
Expand All @@ -12,14 +17,16 @@ vi.mock('./ui.js')
vi.mock('which')
vi.mock('./version.js')

// Mock fs.js to make findPathUpSync controllable for getProjectDir.
// Mock fs.js to make findPathUpSync and fileExistsSync controllable.
// find-up v6 runs returned paths through locatePathSync which checks file existence,
// so we need to mock findPathUpSync directly rather than globSync.
// fileExistsSync is used by getWorkspaceRoot when searching for pnpm-workspace.yaml.
vi.mock('./fs.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('./fs.js')>()
return {
...actual,
findPathUpSync: vi.fn((...args: Parameters<typeof actual.findPathUpSync>) => actual.findPathUpSync(...args)),
fileExistsSync: vi.fn((...args: Parameters<typeof actual.fileExistsSync>) => actual.fileExistsSync(...args)),
}
})

Expand Down Expand Up @@ -81,6 +88,55 @@ describe('currentProcessIsGlobal', () => {
// Then
expect(got).toBeFalsy()
})

test('returns false when no app project found but binary is within a workspace (pnpm shopify from CLI repo)', () => {
// Simulate running `pnpm shopify` from the CLI repo itself:
// - No shopify.app.toml anywhere up the tree (getProjectDir returns undefined)
// - A pnpm-workspace.yaml exists at the repo root (getWorkspaceRoot finds it)
// - The binary lives within the repo (it's a local workspace package)
vi.mocked(findPathUpSync)
// getProjectDir: no shopify.app.toml found
.mockReturnValueOnce(undefined)
// getWorkspaceRoot: found workspace root
.mockReturnValueOnce(`${cwd()}/pnpm-workspace.yaml`)
const argv = ['node', `${cwd()}/packages/cli/bin/shopify.js`, 'shopify']

const got = currentProcessIsGlobal(argv)

expect(got).toBe(false)
})

test('returns true when no app project and no workspace found', () => {
// Simulate a global install in a plain directory with no project context
vi.mocked(findPathUpSync)
// getProjectDir: no shopify.app.toml
.mockReturnValueOnce(undefined)
// getWorkspaceRoot: no pnpm-workspace.yaml
.mockReturnValueOnce(undefined)
const argv = ['node', globalNPMPath, 'shopify']

const got = currentProcessIsGlobal(argv)

expect(got).toBe(true)
})
})

describe('getWorkspaceRoot', () => {
test('returns workspace root when pnpm-workspace.yaml is found', () => {
vi.mocked(findPathUpSync).mockReturnValueOnce(`${cwd()}/pnpm-workspace.yaml`)

const got = getWorkspaceRoot(cwd())

expect(got).toBe(cwd())
})

test('returns undefined when no pnpm-workspace.yaml is found', () => {
vi.mocked(findPathUpSync).mockReturnValueOnce(undefined)

const got = getWorkspaceRoot('/some/random/directory')

expect(got).toBeUndefined()
})
})

describe('inferPackageManagerForGlobalCLI', () => {
Expand Down
37 changes: 33 additions & 4 deletions packages/cli-kit/src/public/node/is-global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {exec, terminalSupportsPrompting} from './system.js'
import {renderSelectPrompt} from './ui.js'
import {globalCLIVersion} from './version.js'
import {isUnitTest} from './context/local.js'
import {findPathUpSync, globSync} from './fs.js'
import {fileExistsSync, findPathUpSync, globSync} from './fs.js'
import {realpathSync} from 'fs'

let _isGlobal: boolean | undefined
Expand All @@ -25,13 +25,19 @@ export function currentProcessIsGlobal(argv = process.argv): boolean {
const path = sniffForPath() ?? cwd()

const projectDir = getProjectDir(path)
if (!projectDir) {
return true
}

// From node docs: "The second element [of the array] will be the path to the JavaScript file being executed"
const binDir = argv[1] ?? ''

if (!projectDir) {
// No app/hydrogen project found. Fall back to checking if the binary is
// within a workspace (monorepo) root — e.g. running `pnpm shopify` from
// the CLI repo itself, which has no shopify.app.toml.
const workspaceRoot = getWorkspaceRoot(cwd())
_isGlobal = !(workspaceRoot && binDir.startsWith(workspaceRoot))
return _isGlobal
}

// If binDir starts with projectDir, then we are running a local CLI
const isLocal = binDir.startsWith(projectDir.trim())

Expand Down Expand Up @@ -116,6 +122,29 @@ export function inferPackageManagerForGlobalCLI(argv = process.argv, env = proce
return 'npm'
}

/**
* Returns the workspace (monorepo) root for the given path by searching upward
* for a pnpm-workspace.yaml file.
*
* @param directory - The path to search upward from.
* @returns The workspace root directory, or undefined if not in a workspace.
*/
export function getWorkspaceRoot(directory: string): string | undefined {
try {
const found = findPathUpSync(
(dir) => {
const yamlPath = joinPath(dir, 'pnpm-workspace.yaml')
return fileExistsSync(yamlPath) ? yamlPath : undefined
},
{cwd: directory, type: 'file'},
)
return found ? dirname(found) : undefined
// eslint-disable-next-line no-catch-all/no-catch-all
} catch (error) {
return undefined
}
}

/**
* Returns the project directory for the given path.
*
Expand Down
Loading