-
Notifications
You must be signed in to change notification settings - Fork 42
Expand file tree
/
Copy pathpackage-environment.mts
More file actions
641 lines (603 loc) · 19.7 KB
/
package-environment.mts
File metadata and controls
641 lines (603 loc) · 19.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
/**
* Package environment detection utilities for Socket CLI.
* Analyzes project environment and package manager configuration.
*
* Key Functions:
* - getPackageEnvironment: Detect package manager and project details
* - makeConcurrentExecLimit: Calculate concurrent execution limits
*
* Environment Detection:
* - Detects npm, pnpm, yarn, bun package managers
* - Analyzes lockfiles for version information
* - Determines Node.js and engine requirements
* - Identifies workspace configurations
*
* Features:
* - Browser target detection via browserslist
* - Engine compatibility checking
* - Package manager version detection
* - Workspace and monorepo support
*
* Usage:
* - Auto-detecting appropriate package manager
* - Validating environment compatibility
* - Configuring concurrent execution limits
*/
import { existsSync } from 'node:fs'
import path from 'node:path'
import browserslist from 'browserslist'
import semver from 'semver'
import { parse as parseBunLockb } from '@socketregistry/hyrious__bun.lockb/index.cjs'
import { resolveBinPathSync, whichBin } from '@socketsecurity/registry/lib/bin'
import { debugDir, debugFn } from '@socketsecurity/registry/lib/debug'
import { readFileBinary, readFileUtf8 } from '@socketsecurity/registry/lib/fs'
import { Logger } from '@socketsecurity/registry/lib/logger'
import { readPackageJson } from '@socketsecurity/registry/lib/packages'
import { naturalCompare } from '@socketsecurity/registry/lib/sorts'
import { spawn } from '@socketsecurity/registry/lib/spawn'
import { isNonEmptyString } from '@socketsecurity/registry/lib/strings'
import { cmdPrefixMessage } from './cmd.mts'
import { findUp } from './fs.mts'
import constants, {
FLAG_VERSION,
PACKAGE_LOCK_JSON,
PNPM_LOCK_YAML,
YARN_LOCK,
} from '../constants.mts'
import type { CResult } from '../types.mts'
import type { Remap } from '@socketsecurity/registry/lib/objects'
import type { EditablePackageJson } from '@socketsecurity/registry/lib/packages'
import type { SemVer } from 'semver'
const {
BUN,
BUN_LOCK,
BUN_LOCKB,
DOT_PACKAGE_LOCK_JSON,
EXT_LOCK,
EXT_LOCKB,
NODE_MODULES,
NPM,
NPM_BUGGY_OVERRIDES_PATCHED_VERSION,
NPM_SHRINKWRAP_JSON,
PACKAGE_JSON,
PNPM,
VLT,
VLT_LOCK_JSON,
YARN,
YARN_BERRY,
YARN_CLASSIC,
} = constants
export const AGENTS = [BUN, NPM, PNPM, YARN_BERRY, YARN_CLASSIC, VLT] as const
const binByAgent = new Map<Agent, string>([
[BUN, BUN],
[NPM, NPM],
[PNPM, PNPM],
[YARN_BERRY, YARN],
[YARN_CLASSIC, YARN],
[VLT, VLT],
])
export type Agent = (typeof AGENTS)[number]
export type EnvBase = {
agent: Agent
agentExecPath: string
agentSupported: boolean
features: {
// Fixed by https://github.com/npm/cli/pull/8089.
// Landed in npm v11.2.0.
npmBuggyOverrides: boolean
}
nodeSupported: boolean
nodeVersion: SemVer
npmExecPath: string
pkgRequirements: {
agent: string
node: string
}
pkgSupports: {
agent: boolean
node: boolean
}
}
export type EnvDetails = Readonly<
Remap<
EnvBase & {
agentVersion: SemVer
editablePkgJson: EditablePackageJson
lockName: string
lockPath: string
lockSrc: string
pkgPath: string
}
>
>
export type DetectAndValidateOptions = {
cmdName?: string | undefined
logger?: Logger | undefined
prod?: boolean | undefined
}
export type DetectOptions = {
cwd?: string | undefined
onUnknown?: (pkgManager: string | undefined) => void
}
export type PartialEnvDetails = Readonly<
Remap<
EnvBase & {
agentVersion: SemVer | undefined
editablePkgJson: EditablePackageJson | undefined
lockName: string | undefined
lockPath: string | undefined
lockSrc: string | undefined
pkgPath: string | undefined
}
>
>
export type ReadLockFile =
| ((lockPath: string) => Promise<string | undefined>)
| ((lockPath: string, agentExecPath: string) => Promise<string | undefined>)
| ((
lockPath: string,
agentExecPath: string,
cwd: string,
) => Promise<string | undefined>)
const readLockFileByAgent: Map<Agent, ReadLockFile> = (() => {
function wrapReader<T extends (...args: any[]) => Promise<any>>(
reader: T,
): (...args: Parameters<T>) => Promise<Awaited<ReturnType<T>> | undefined> {
return async (...args: any[]): Promise<any> => {
try {
return await reader(...args)
} catch {}
return undefined
}
}
const binaryReader = wrapReader(readFileBinary)
const defaultReader = wrapReader(
async (lockPath: string) => await readFileUtf8(lockPath),
)
return new Map([
[
BUN,
wrapReader(
async (
lockPath: string,
agentExecPath: string,
cwd = process.cwd(),
) => {
const ext = path.extname(lockPath)
if (ext === EXT_LOCK) {
return await defaultReader(lockPath)
}
if (ext === EXT_LOCKB) {
const lockBuffer = await binaryReader(lockPath)
if (lockBuffer) {
try {
return parseBunLockb(lockBuffer)
} catch {}
}
// To print a Yarn lockfile to your console without writing it to disk
// use `bun bun.lockb`.
// https://bun.sh/guides/install/yarnlock
return (
await spawn(agentExecPath, [lockPath], {
cwd,
// On Windows, bun is often a .cmd file that requires shell execution.
// The spawn function from @socketsecurity/registry will handle this properly
// when shell is true.
shell: constants.WIN32,
})
).stdout
}
return undefined
},
),
],
[NPM, defaultReader],
[PNPM, defaultReader],
[VLT, defaultReader],
[YARN_BERRY, defaultReader],
[YARN_CLASSIC, defaultReader],
])
})()
// The order of LOCKS properties IS significant as it affects iteration order.
const LOCKS: Record<string, Agent> = {
[BUN_LOCK]: BUN,
[BUN_LOCKB]: BUN,
// If both package-lock.json and npm-shrinkwrap.json are present in the root
// of a project, npm-shrinkwrap.json will take precedence and package-lock.json
// will be ignored.
// https://docs.npmjs.com/cli/v10/configuring-npm/package-lock-json#package-lockjson-vs-npm-shrinkwrapjson
[NPM_SHRINKWRAP_JSON]: NPM,
[PACKAGE_LOCK_JSON]: NPM,
[PNPM_LOCK_YAML]: PNPM,
[YARN_LOCK]: YARN_CLASSIC,
[VLT_LOCK_JSON]: VLT,
// Lastly, look for a hidden lockfile which is present if .npmrc has package-lock=false:
// https://docs.npmjs.com/cli/v10/configuring-npm/package-lock-json#hidden-lockfiles
//
// Unlike the other LOCKS keys this key contains a directory AND filename so
// it has to be handled differently.
[`${NODE_MODULES}/${DOT_PACKAGE_LOCK_JSON}`]: NPM,
}
function preferWindowsCmdShim(binPath: string, binName: string): string {
// Only Windows uses .cmd shims
if (!constants.WIN32) {
return binPath
}
// Relative paths might be shell commands or aliases, not file paths with potential shims
if (!path.isAbsolute(binPath)) {
return binPath
}
// If the path already has an extension (.exe, .bat, etc.), it is probably a Windows executable
if (path.extname(binPath) !== '') {
return binPath
}
// Ensures binPath actually points to the expected binary, not a parent directory that happens to match `binName`
// For example, if binPath is C:\foo\npm\something and binName is npm, we shouldn't replace it
if (path.basename(binPath).toLowerCase() !== binName.toLowerCase()) {
return binPath
}
// Finally attempt to construct a .cmd shim from binPAth
const cmdShim = path.join(path.dirname(binPath), `${binName}.cmd`)
// Ensure shim exists, otherwise failback to binPath
return existsSync(cmdShim) ? cmdShim : binPath
}
async function getAgentExecPath(agent: Agent): Promise<string> {
const binName = binByAgent.get(agent)!
if (binName === NPM) {
// Try to use constants.npmExecPath first, but verify it exists.
const npmPath = preferWindowsCmdShim(constants.npmExecPath, NPM)
if (existsSync(npmPath)) {
return npmPath
}
// If npmExecPath doesn't exist, try common locations.
// Check npm in the same directory as node.
const nodeDir = path.dirname(process.execPath)
if (constants.WIN32) {
const npmCmdInNodeDir = path.join(nodeDir, `${NPM}.cmd`)
if (existsSync(npmCmdInNodeDir)) {
return npmCmdInNodeDir
}
}
const npmInNodeDir = path.join(nodeDir, NPM)
if (existsSync(npmInNodeDir)) {
return preferWindowsCmdShim(npmInNodeDir, NPM)
}
// Fall back to whichBin.
return (await whichBin(binName, { nothrow: true })) ?? binName
}
if (binName === PNPM) {
// Try to use constants.pnpmExecPath first, but verify it exists.
const pnpmPath = constants.pnpmExecPath
if (existsSync(pnpmPath)) {
return pnpmPath
}
// Fall back to whichBin.
return (await whichBin(binName, { nothrow: true })) ?? binName
}
return (await whichBin(binName, { nothrow: true })) ?? binName
}
async function getAgentVersion(
agent: Agent,
agentExecPath: string,
cwd: string,
): Promise<SemVer | undefined> {
let result
const quotedCmd = `\`${agent} ${FLAG_VERSION}\``
debugFn('stdio', `spawn: ${quotedCmd}`)
try {
let stdout: string
// Some package manager "executables" may resolve to non-executable wrapper scripts
// (e.g. the extensionless `npm` shim on Windows). Resolve the underlying entrypoint
// and run it with Node when it is a JS file.
let shouldRunWithNode: string | null = null
if (constants.WIN32) {
try {
const resolved = resolveBinPathSync(agentExecPath)
const ext = path.extname(resolved).toLowerCase()
if (ext === '.js' || ext === '.cjs' || ext === '.mjs') {
shouldRunWithNode = resolved
}
} catch (e) {
debugFn('warn', `Failed to resolve bin path for ${agentExecPath}, falling back to direct spawn.`)
debugDir('error', e)
}
}
if (shouldRunWithNode) {
stdout = (
await spawn(
constants.execPath,
[...constants.nodeNoWarningsFlags, shouldRunWithNode, FLAG_VERSION],
{ cwd },
)
).stdout
} else {
stdout = (
await spawn(agentExecPath, [FLAG_VERSION], {
cwd,
// On Windows, package managers are often .cmd files that require shell execution.
// The spawn function from @socketsecurity/registry will handle this properly
// when shell is true.
shell: constants.WIN32,
})
).stdout
}
result =
// Coerce version output into a valid semver version by passing it through
// semver.coerce which strips leading v's, carets (^), comparators (<,<=,>,>=,=),
// and tildes (~).
semver.coerce(stdout) ?? undefined
} catch (e) {
debugFn('error', `Package manager command failed: ${quotedCmd}`)
debugDir('inspect', { cmd: quotedCmd })
debugDir('error', e)
}
return result
}
export async function detectPackageEnvironment({
cwd = process.cwd(),
onUnknown,
}: DetectOptions = {}): Promise<EnvDetails | PartialEnvDetails> {
let lockPath = await findUp(Object.keys(LOCKS), { cwd })
let lockName = lockPath ? path.basename(lockPath) : undefined
const isHiddenLockFile = lockName === DOT_PACKAGE_LOCK_JSON
const pkgJsonPath = lockPath
? path.resolve(
lockPath,
`${isHiddenLockFile ? '../' : ''}../${PACKAGE_JSON}`,
)
: await findUp(PACKAGE_JSON, { cwd })
const pkgPath =
pkgJsonPath && existsSync(pkgJsonPath)
? path.dirname(pkgJsonPath)
: undefined
const editablePkgJson = pkgPath
? await readPackageJson(pkgPath, { editable: true })
: undefined
// Read Corepack `packageManager` field in package.json:
// https://nodejs.org/api/packages.html#packagemanager
const pkgManager = isNonEmptyString(editablePkgJson?.content?.packageManager)
? editablePkgJson.content.packageManager
: undefined
let agent: Agent | undefined
if (pkgManager) {
// A valid "packageManager" field value is "<package manager name>@<version>".
// https://nodejs.org/api/packages.html#packagemanager
const atSignIndex = pkgManager.lastIndexOf('@')
if (atSignIndex !== -1) {
const name = pkgManager.slice(0, atSignIndex) as Agent
const version = pkgManager.slice(atSignIndex + 1)
if (version && AGENTS.includes(name)) {
agent = name
}
}
}
if (
agent === undefined &&
!isHiddenLockFile &&
typeof pkgJsonPath === 'string' &&
typeof lockName === 'string'
) {
agent = LOCKS[lockName] as Agent
}
if (agent === undefined) {
agent = NPM
onUnknown?.(pkgManager)
}
const agentExecPath = await getAgentExecPath(agent)
const agentVersion = await getAgentVersion(agent, agentExecPath, cwd)
if (agent === YARN_CLASSIC && (agentVersion?.major ?? 0) > 1) {
agent = YARN_BERRY
}
const { maintainedNodeVersions } = constants
const minSupportedAgentVersion = constants.minimumVersionByAgent.get(agent)!
const minSupportedNodeMajor = semver.major(maintainedNodeVersions.last)
const minSupportedNodeVersion = `${minSupportedNodeMajor}.0.0`
const minSupportedNodeRange = `>=${minSupportedNodeMajor}`
const nodeVersion = semver.coerce(process.version)!
let lockSrc: string | undefined
let pkgAgentRange: string | undefined
let pkgNodeRange: string | undefined
let pkgMinAgentVersion = minSupportedAgentVersion
let pkgMinNodeVersion = minSupportedNodeVersion
if (editablePkgJson?.content) {
const { engines } = editablePkgJson.content
const engineAgentRange = engines?.[agent]
const engineNodeRange = engines?.['node']
if (isNonEmptyString(engineAgentRange)) {
pkgAgentRange = engineAgentRange
// Roughly check agent range as semver.coerce will strip leading
// v's, carets (^), comparators (<,<=,>,>=,=), and tildes (~).
const coerced = semver.coerce(pkgAgentRange)
if (coerced && semver.lt(coerced, pkgMinAgentVersion)) {
pkgMinAgentVersion = coerced.version
}
}
if (isNonEmptyString(engineNodeRange)) {
pkgNodeRange = engineNodeRange
// Roughly check Node range as semver.coerce will strip leading
// v's, carets (^), comparators (<,<=,>,>=,=), and tildes (~).
const coerced = semver.coerce(pkgNodeRange)
if (coerced && semver.lt(coerced, pkgMinNodeVersion)) {
pkgMinNodeVersion = coerced.version
}
}
const browserslistQuery = editablePkgJson.content['browserslist'] as
| string[]
| undefined
if (Array.isArray(browserslistQuery)) {
// List Node targets in ascending version order.
const browserslistNodeTargets = browserslist(browserslistQuery)
.filter(v => /^node /i.test(v))
.map(v => v.slice(5 /*'node '.length*/))
.sort(naturalCompare)
if (browserslistNodeTargets.length) {
// browserslistNodeTargets[0] is the lowest Node target version.
const coerced = semver.coerce(browserslistNodeTargets[0])
if (coerced && semver.lt(coerced, pkgMinNodeVersion)) {
pkgMinNodeVersion = coerced.version
}
}
}
lockSrc =
typeof lockPath === 'string'
? await readLockFileByAgent.get(agent)!(lockPath, agentExecPath, cwd)
: undefined
} else {
lockName = undefined
lockPath = undefined
}
// Does the system agent version meet our minimum supported agent version?
const agentSupported =
!!agentVersion &&
semver.satisfies(agentVersion, `>=${minSupportedAgentVersion}`)
// Does the system Node version meet our minimum supported Node version?
const nodeSupported = semver.satisfies(nodeVersion, minSupportedNodeRange)
const npmExecPath =
agent === NPM ? agentExecPath : await getAgentExecPath(NPM)
const npmBuggyOverrides =
agent === NPM &&
!!agentVersion &&
semver.lt(agentVersion, NPM_BUGGY_OVERRIDES_PATCHED_VERSION)
const pkgMinAgentRange = `>=${pkgMinAgentVersion}`
const pkgMinNodeRange = `>=${semver.major(pkgMinNodeVersion)}`
return {
agent,
agentExecPath,
agentSupported,
agentVersion,
editablePkgJson,
features: { npmBuggyOverrides },
lockName,
lockPath,
lockSrc,
nodeSupported,
nodeVersion,
npmExecPath,
pkgPath,
pkgRequirements: {
agent: pkgAgentRange ?? pkgMinAgentRange,
node: pkgNodeRange ?? pkgMinNodeRange,
},
pkgSupports: {
// Does our minimum supported agent version meet the package's requirements?
agent: semver.satisfies(minSupportedAgentVersion, pkgMinAgentRange),
// Does our supported Node versions meet the package's requirements?
node: maintainedNodeVersions.some(v =>
semver.satisfies(v, pkgMinNodeRange),
),
},
}
}
export async function detectAndValidatePackageEnvironment(
cwd: string,
options?: DetectAndValidateOptions | undefined,
): Promise<CResult<EnvDetails>> {
const {
cmdName = '',
logger,
prod,
} = {
__proto__: null,
...options,
} as DetectAndValidateOptions
const details = await detectPackageEnvironment({
cwd,
onUnknown(pkgManager: string | undefined) {
logger?.warn(
cmdPrefixMessage(
cmdName,
`Unknown package manager${pkgManager ? ` ${pkgManager}` : ''}, defaulting to ${NPM}`,
),
)
},
})
const { agent, nodeVersion, pkgRequirements } = details
const agentVersion = details.agentVersion ?? 'unknown'
if (!details.agentSupported) {
const minVersion = constants.minimumVersionByAgent.get(agent)!
return {
ok: false,
message: 'Version mismatch',
cause: cmdPrefixMessage(
cmdName,
`Requires ${agent} >=${minVersion}. Current version: ${agentVersion}.`,
),
}
}
if (!details.nodeSupported) {
const minVersion = constants.maintainedNodeVersions.last
return {
ok: false,
message: 'Version mismatch',
cause: cmdPrefixMessage(
cmdName,
`Requires Node >=${minVersion}. Current version: ${nodeVersion}.`,
),
}
}
if (!details.pkgSupports.agent) {
return {
ok: false,
message: 'Engine mismatch',
cause: cmdPrefixMessage(
cmdName,
`Package engine "${agent}" requires ${pkgRequirements.agent}. Current version: ${agentVersion}`,
),
}
}
if (!details.pkgSupports.node) {
return {
ok: false,
message: 'Version mismatch',
cause: cmdPrefixMessage(
cmdName,
`Package engine "node" requires ${pkgRequirements.node}. Current version: ${nodeVersion}`,
),
}
}
const lockName = details.lockName ?? 'lockfile'
if (details.lockName === undefined || details.lockSrc === undefined) {
return {
ok: false,
message: 'Missing lockfile',
cause: cmdPrefixMessage(cmdName, `No ${lockName} found`),
}
}
if (details.lockSrc.trim() === '') {
return {
ok: false,
message: 'Empty lockfile',
cause: cmdPrefixMessage(cmdName, `${lockName} is empty`),
}
}
if (details.pkgPath === undefined) {
return {
ok: false,
message: 'Missing package.json',
cause: cmdPrefixMessage(cmdName, `No ${PACKAGE_JSON} found`),
}
}
if (prod && (agent === BUN || agent === YARN_BERRY)) {
return {
ok: false,
message: 'Bad input',
cause: cmdPrefixMessage(
cmdName,
`--prod not supported for ${agent}${agentVersion ? `@${agentVersion}` : ''}`,
),
}
}
if (
details.lockPath &&
path.relative(cwd, details.lockPath).startsWith('.')
) {
// Note: In tests we return <redacted> because otherwise snapshots will fail.
logger?.warn(
cmdPrefixMessage(
cmdName,
`Package ${lockName} found at ${constants.ENV.VITEST ? constants.REDACTED : details.lockPath}`,
),
)
}
return { ok: true, data: details as EnvDetails }
}