diff --git a/packages/cli-kit/src/public/node/is-global.test.ts b/packages/cli-kit/src/public/node/is-global.test.ts index 53bd98352f..258315cdc5 100644 --- a/packages/cli-kit/src/public/node/is-global.test.ts +++ b/packages/cli-kit/src/public/node/is-global.test.ts @@ -246,6 +246,25 @@ describe('inferPackageManagerForGlobalCLI', () => { expect(got).toBe('homebrew') }) + test('returns bun when symlink under ~/.bun/bin resolves out of the bun install dir', async () => { + // Given: `bun add -g @shopify/cli` creates ~/.bun/bin/shopify as a symlink to + // ../../node_modules/@shopify/cli/bin/run.js, which on most setups resolves to + // /node_modules/@shopify/cli/bin/run.js — a path that does NOT contain "bun". + // Without inspecting the original symlink path we'd fall through to npm and the + // autoupgrade flow would shell out to `npm install -g` instead of `bun add -g`. + const symlinkPath = '/users/fonso/.bun/bin/shopify' + const realBunPath = '/users/fonso/node_modules/@shopify/cli/bin/run.js' + const argv = ['node', symlinkPath, 'shopify'] + + vi.mocked(realpathSync).mockImplementationOnce(() => realBunPath) + + // When + const got = inferPackageManagerForGlobalCLI(argv) + + // Then + expect(got).toBe('bun') + }) + test('defaults to npm if realpath fails and no other indicator is present', async () => { // Given: A path that realpathSync cannot resolve const nonExistentPath = '/opt/homebrew/bin/shopify' diff --git a/packages/cli-kit/src/public/node/is-global.ts b/packages/cli-kit/src/public/node/is-global.ts index 068e61f95f..2a60d6745b 100644 --- a/packages/cli-kit/src/public/node/is-global.ts +++ b/packages/cli-kit/src/public/node/is-global.ts @@ -105,9 +105,10 @@ export function inferPackageManagerForGlobalCLI(argv = process.argv, env = proce } const processArgv = argv[1] ?? '' + const symlinkPath = processArgv.toLowerCase() // Resolve symlinks to get the real path of the binary. - let realPath = processArgv.toLowerCase() + let realPath = symlinkPath try { realPath = realpathSync(processArgv).toLowerCase() // eslint-disable-next-line no-catch-all/no-catch-all @@ -115,9 +116,16 @@ export function inferPackageManagerForGlobalCLI(argv = process.argv, env = proce // fall back to using the original path for detection } - if (realPath.includes('yarn')) return 'yarn' - if (realPath.includes('pnpm')) return 'pnpm' - if (realPath.includes('bun')) return 'bun' + // Inspect both the (unresolved) symlink path and the resolved real path. Some + // package managers — notably bun (`~/.bun/bin/`) — install global binaries + // as symlinks pointing into a generic `node_modules` directory whose real path + // no longer contains the package manager name. The original symlink under the + // PM's bin dir is the most reliable signal in that case. + const matches = (needle: string) => realPath.includes(needle) || symlinkPath.includes(needle) + + if (matches('yarn')) return 'yarn' + if (matches('pnpm')) return 'pnpm' + if (matches('bun')) return 'bun' // Check for Homebrew via Cellar path (resolved symlink) if (realPath.includes('/cellar/')) return 'homebrew'