Skip to content

Commit b266e3f

Browse files
committed
fix(package-env): improve Windows npm version detection
1 parent b40531e commit b266e3f

File tree

2 files changed

+160
-15
lines changed

2 files changed

+160
-15
lines changed

src/utils/package-environment.mts

Lines changed: 64 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import browserslist from 'browserslist'
3131
import semver from 'semver'
3232

3333
import { parse as parseBunLockb } from '@socketregistry/hyrious__bun.lockb/index.cjs'
34-
import { whichBin } from '@socketsecurity/registry/lib/bin'
34+
import { resolveBinPathSync, whichBin } from '@socketsecurity/registry/lib/bin'
3535
import { debugDir, debugFn } from '@socketsecurity/registry/lib/debug'
3636
import { readFileBinary, readFileUtf8 } from '@socketsecurity/registry/lib/fs'
3737
import { Logger } from '@socketsecurity/registry/lib/logger'
@@ -239,20 +239,43 @@ const LOCKS: Record<string, Agent> = {
239239
[`${NODE_MODULES}/${DOT_PACKAGE_LOCK_JSON}`]: NPM,
240240
}
241241

242+
function preferWindowsCmdShim(binPath: string, binName: string): string {
243+
if (!constants.WIN32) {
244+
return binPath
245+
}
246+
if (!path.isAbsolute(binPath)) {
247+
return binPath
248+
}
249+
if (path.extname(binPath) !== '') {
250+
return binPath
251+
}
252+
if (path.basename(binPath).toLowerCase() !== binName.toLowerCase()) {
253+
return binPath
254+
}
255+
const cmdShim = path.join(path.dirname(binPath), `${binName}.cmd`)
256+
return existsSync(cmdShim) ? cmdShim : binPath
257+
}
258+
242259
async function getAgentExecPath(agent: Agent): Promise<string> {
243260
const binName = binByAgent.get(agent)!
244261
if (binName === NPM) {
245262
// Try to use constants.npmExecPath first, but verify it exists.
246-
const npmPath = constants.npmExecPath
263+
const npmPath = preferWindowsCmdShim(constants.npmExecPath, NPM)
247264
if (existsSync(npmPath)) {
248265
return npmPath
249266
}
250267
// If npmExecPath doesn't exist, try common locations.
251268
// Check npm in the same directory as node.
252269
const nodeDir = path.dirname(process.execPath)
270+
if (constants.WIN32) {
271+
const npmCmdInNodeDir = path.join(nodeDir, `${NPM}.cmd`)
272+
if (existsSync(npmCmdInNodeDir)) {
273+
return npmCmdInNodeDir
274+
}
275+
}
253276
const npmInNodeDir = path.join(nodeDir, NPM)
254277
if (existsSync(npmInNodeDir)) {
255-
return npmInNodeDir
278+
return preferWindowsCmdShim(npmInNodeDir, NPM)
256279
}
257280
// Fall back to whichBin.
258281
return (await whichBin(binName, { nothrow: true })) ?? binName
@@ -278,22 +301,48 @@ async function getAgentVersion(
278301
const quotedCmd = `\`${agent} ${FLAG_VERSION}\``
279302
debugFn('stdio', `spawn: ${quotedCmd}`)
280303
try {
304+
let stdout: string
305+
306+
// Some package manager "executables" may resolve to non-executable wrapper scripts
307+
// (e.g. the extensionless `npm` shim on Windows). Resolve the underlying entrypoint
308+
// and run it with Node when it is a JS file.
309+
let shouldRunWithNode: string | null = null
310+
try {
311+
const resolved = resolveBinPathSync(agentExecPath)
312+
const ext = path.extname(resolved).toLowerCase()
313+
if (ext === '.js' || ext === '.cjs' || ext === '.mjs') {
314+
shouldRunWithNode = resolved
315+
}
316+
} catch (e) {
317+
debugFn('warn', `Failed to resolve bin path for ${agentExecPath}, falling back to direct spawn.`)
318+
debugDir('error', e)
319+
}
320+
321+
if (shouldRunWithNode) {
322+
stdout = (
323+
await spawn(
324+
constants.execPath,
325+
[...constants.nodeNoWarningsFlags, shouldRunWithNode, FLAG_VERSION],
326+
{ cwd },
327+
)
328+
).stdout
329+
} else {
330+
stdout = (
331+
await spawn(agentExecPath, [FLAG_VERSION], {
332+
cwd,
333+
// On Windows, package managers are often .cmd files that require shell execution.
334+
// The spawn function from @socketsecurity/registry will handle this properly
335+
// when shell is true.
336+
shell: constants.WIN32,
337+
})
338+
).stdout
339+
}
340+
281341
result =
282342
// Coerce version output into a valid semver version by passing it through
283343
// semver.coerce which strips leading v's, carets (^), comparators (<,<=,>,>=,=),
284344
// and tildes (~).
285-
semver.coerce(
286-
// All package managers support the "--version" flag.
287-
(
288-
await spawn(agentExecPath, [FLAG_VERSION], {
289-
cwd,
290-
// On Windows, package managers are often .cmd files that require shell execution.
291-
// The spawn function from @socketsecurity/registry will handle this properly
292-
// when shell is true.
293-
shell: constants.WIN32,
294-
})
295-
).stdout,
296-
) ?? undefined
345+
semver.coerce(stdout) ?? undefined
297346
} catch (e) {
298347
debugFn('error', `Package manager command failed: ${quotedCmd}`)
299348
debugDir('inspect', { cmd: quotedCmd })
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { describe, expect, it, vi } from 'vitest'
2+
3+
const spawnMock = vi.fn(async () => ({ stdout: '11.6.0' }))
4+
const resolveBinPathSyncMock = vi.fn(() => '/fake/npm-cli.js')
5+
const whichBinMock = vi.fn(async () => 'npm')
6+
7+
vi.mock('@socketsecurity/registry/lib/spawn', () => ({
8+
spawn: spawnMock,
9+
}))
10+
11+
vi.mock('@socketsecurity/registry/lib/bin', () => ({
12+
resolveBinPathSync: resolveBinPathSyncMock,
13+
whichBin: whichBinMock,
14+
}))
15+
16+
vi.mock('../src/utils/fs.mts', () => ({
17+
findUp: vi.fn(async () => undefined),
18+
}))
19+
20+
describe('detectPackageEnvironment', () => {
21+
it('detects npm version when resolved to JS entrypoint', async () => {
22+
vi.resetModules()
23+
spawnMock.mockClear()
24+
resolveBinPathSyncMock.mockClear()
25+
whichBinMock.mockClear()
26+
resolveBinPathSyncMock.mockReturnValue('/fake/npm-cli.js')
27+
28+
const { detectPackageEnvironment } = await import(
29+
'../src/utils/package-environment.mts'
30+
)
31+
const details = await detectPackageEnvironment({ cwd: process.cwd() })
32+
33+
expect(details.agent).toBe('npm')
34+
expect(details.agentVersion?.major).toBe(11)
35+
36+
expect(spawnMock).toHaveBeenCalledWith(
37+
expect.any(String),
38+
expect.arrayContaining(['/fake/npm-cli.js', '--version']),
39+
expect.objectContaining({ cwd: process.cwd() }),
40+
)
41+
})
42+
43+
it('falls back to direct spawn when resolveBinPathSync fails', async () => {
44+
vi.resetModules()
45+
spawnMock.mockClear()
46+
resolveBinPathSyncMock.mockClear()
47+
whichBinMock.mockClear()
48+
resolveBinPathSyncMock.mockImplementation(() => {
49+
throw new Error('Resolution failed')
50+
})
51+
spawnMock.mockResolvedValue({ stdout: '10.5.0' })
52+
53+
const { detectPackageEnvironment } = await import(
54+
'../src/utils/package-environment.mts'
55+
)
56+
const details = await detectPackageEnvironment({ cwd: process.cwd() })
57+
58+
expect(details.agent).toBe('npm')
59+
expect(details.agentVersion?.major).toBe(10)
60+
61+
expect(spawnMock).toHaveBeenCalledWith(
62+
expect.any(String),
63+
['--version'],
64+
expect.objectContaining({
65+
cwd: process.cwd(),
66+
shell: expect.anything(),
67+
}),
68+
)
69+
})
70+
71+
it('uses direct spawn when resolved to non-JS executable', async () => {
72+
vi.resetModules()
73+
spawnMock.mockClear()
74+
resolveBinPathSyncMock.mockClear()
75+
whichBinMock.mockClear()
76+
resolveBinPathSyncMock.mockReturnValue('/fake/npm.cmd')
77+
spawnMock.mockResolvedValue({ stdout: '9.8.1' })
78+
79+
const { detectPackageEnvironment } = await import(
80+
'../src/utils/package-environment.mts'
81+
)
82+
const details = await detectPackageEnvironment({ cwd: process.cwd() })
83+
84+
expect(details.agent).toBe('npm')
85+
expect(details.agentVersion?.major).toBe(9)
86+
87+
expect(spawnMock).toHaveBeenCalledWith(
88+
expect.any(String),
89+
['--version'],
90+
expect.objectContaining({
91+
cwd: process.cwd(),
92+
shell: expect.anything(),
93+
}),
94+
)
95+
})
96+
})

0 commit comments

Comments
 (0)