|
| 1 | +import path from 'node:path' |
| 2 | + |
| 3 | +import npa from 'npm-package-arg' |
| 4 | +import semver from 'semver' |
| 5 | +import { glob as tinyGlob } from 'tinyglobby' |
| 6 | + |
| 7 | +import { getManifestData } from '@socketsecurity/registry' |
| 8 | +import { hasOwn, toSortedObject } from '@socketsecurity/registry/lib/objects' |
| 9 | +import { |
| 10 | + fetchPackageManifest, |
| 11 | + readPackageJson |
| 12 | +} from '@socketsecurity/registry/lib/packages' |
| 13 | +import { pEach } from '@socketsecurity/registry/lib/promises' |
| 14 | +import { Spinner } from '@socketsecurity/registry/lib/spinner' |
| 15 | + |
| 16 | +import { depsIncludesByAgent } from './deps-includes-by-agent' |
| 17 | +import { getDependencyEntries } from './get-dependency-entries' |
| 18 | +import { overridesDataByAgent } from './get-overrides-by-agent' |
| 19 | +import { getWorkspaceGlobs } from './get-workspace-globs' |
| 20 | +import { lockfileIncludesByAgent } from './lockfile-includes-by-agent' |
| 21 | +import { lsByAgent } from './ls-by-agent' |
| 22 | +import { updateManifestByAgent } from './update-manifest-by-agent' |
| 23 | +import constants from '../../constants' |
| 24 | +import { cmdPrefixMessage } from '../../utils/cmd' |
| 25 | + |
| 26 | +import type { AgentLockIncludesFn } from './lockfile-includes-by-agent' |
| 27 | +import type { |
| 28 | + Agent, |
| 29 | + EnvDetails, |
| 30 | + StringKeyValueObject |
| 31 | +} from '../../utils/package-environment' |
| 32 | +import type { Logger } from '@socketsecurity/registry/lib/logger' |
| 33 | + |
| 34 | +type AddOverridesOptions = { |
| 35 | + logger?: Logger | undefined |
| 36 | + pin?: boolean | undefined |
| 37 | + prod?: boolean | undefined |
| 38 | + spinner?: Spinner | undefined |
| 39 | + state?: AddOverridesState | undefined |
| 40 | +} |
| 41 | +type AddOverridesState = { |
| 42 | + added: Set<string> |
| 43 | + addedInWorkspaces: Set<string> |
| 44 | + updated: Set<string> |
| 45 | + updatedInWorkspaces: Set<string> |
| 46 | + warnedPnpmWorkspaceRequiresNpm: boolean |
| 47 | +} |
| 48 | +type GetOverridesResult = { type: Agent; overrides: Overrides } |
| 49 | +type NpmOverrides = { [key: string]: string | StringKeyValueObject } |
| 50 | +type PackageJson = Awaited<ReturnType<typeof readPackageJson>> |
| 51 | +type PnpmOrYarnOverrides = { [key: string]: string } |
| 52 | +type Overrides = NpmOverrides | PnpmOrYarnOverrides |
| 53 | + |
| 54 | +const { NPM, PNPM, YARN_CLASSIC } = constants |
| 55 | + |
| 56 | +const CMD_NAME = 'socket optimize' |
| 57 | + |
| 58 | +const manifestNpmOverrides = getManifestData(NPM) |
| 59 | + |
| 60 | +export async function addOverrides( |
| 61 | + pkgPath: string, |
| 62 | + pkgEnvDetails: EnvDetails, |
| 63 | + options?: AddOverridesOptions | undefined |
| 64 | +): Promise<AddOverridesState> { |
| 65 | + const { |
| 66 | + agent, |
| 67 | + agentExecPath, |
| 68 | + lockName, |
| 69 | + lockSrc, |
| 70 | + npmExecPath, |
| 71 | + pkgPath: rootPath |
| 72 | + } = pkgEnvDetails |
| 73 | + const { |
| 74 | + logger, |
| 75 | + pin, |
| 76 | + prod, |
| 77 | + spinner, |
| 78 | + state = { |
| 79 | + added: new Set(), |
| 80 | + addedInWorkspaces: new Set(), |
| 81 | + updated: new Set(), |
| 82 | + updatedInWorkspaces: new Set(), |
| 83 | + warnedPnpmWorkspaceRequiresNpm: false |
| 84 | + } |
| 85 | + } = { __proto__: null, ...options } as AddOverridesOptions |
| 86 | + let { pkgJson: editablePkgJson } = pkgEnvDetails |
| 87 | + if (editablePkgJson === undefined) { |
| 88 | + editablePkgJson = await readPackageJson(pkgPath, { editable: true }) |
| 89 | + } |
| 90 | + const { content: pkgJson } = editablePkgJson |
| 91 | + |
| 92 | + const workspaceName = path.relative(rootPath, pkgPath) |
| 93 | + const workspaceGlobs = await getWorkspaceGlobs(agent, pkgPath, pkgJson) |
| 94 | + const isRoot = pkgPath === rootPath |
| 95 | + const isLockScanned = isRoot && !prod |
| 96 | + const isWorkspace = !!workspaceGlobs |
| 97 | + if ( |
| 98 | + isWorkspace && |
| 99 | + agent === PNPM && |
| 100 | + // npmExecPath will === the agent name IF it CANNOT be resolved. |
| 101 | + npmExecPath === NPM && |
| 102 | + !state.warnedPnpmWorkspaceRequiresNpm |
| 103 | + ) { |
| 104 | + state.warnedPnpmWorkspaceRequiresNpm = true |
| 105 | + logger?.warn( |
| 106 | + cmdPrefixMessage( |
| 107 | + CMD_NAME, |
| 108 | + `${agent} workspace support requires \`npm ls\`, falling back to \`${agent} list\`` |
| 109 | + ) |
| 110 | + ) |
| 111 | + } |
| 112 | + |
| 113 | + const overridesDataObjects = [] as GetOverridesResult[] |
| 114 | + if (pkgJson['private'] || isWorkspace) { |
| 115 | + overridesDataObjects.push(overridesDataByAgent.get(agent)!(pkgJson)) |
| 116 | + } else { |
| 117 | + overridesDataObjects.push( |
| 118 | + overridesDataByAgent.get(NPM)!(pkgJson), |
| 119 | + overridesDataByAgent.get(YARN_CLASSIC)!(pkgJson) |
| 120 | + ) |
| 121 | + } |
| 122 | + |
| 123 | + spinner?.setText( |
| 124 | + `Adding overrides${workspaceName ? ` to ${workspaceName}` : ''}...` |
| 125 | + ) |
| 126 | + |
| 127 | + const depAliasMap = new Map<string, string>() |
| 128 | + const depEntries = getDependencyEntries(pkgJson) |
| 129 | + |
| 130 | + const nodeRange = `>=${pkgEnvDetails.minimumNodeVersion}` |
| 131 | + const manifestEntries = manifestNpmOverrides.filter(({ 1: data }) => |
| 132 | + semver.satisfies(semver.coerce(data.engines.node)!, nodeRange) |
| 133 | + ) |
| 134 | + |
| 135 | + // Chunk package names to process them in parallel 3 at a time. |
| 136 | + await pEach(manifestEntries, 3, async ({ 1: data }) => { |
| 137 | + const { name: sockRegPkgName, package: origPkgName, version } = data |
| 138 | + const major = semver.major(version) |
| 139 | + const sockOverridePrefix = `${NPM}:${sockRegPkgName}@` |
| 140 | + const sockOverrideSpec = `${sockOverridePrefix}${pin ? version : `^${major}`}` |
| 141 | + for (const { 1: depObj } of depEntries) { |
| 142 | + const sockSpec = hasOwn(depObj, sockRegPkgName) |
| 143 | + ? depObj[sockRegPkgName] |
| 144 | + : undefined |
| 145 | + if (sockSpec) { |
| 146 | + depAliasMap.set(sockRegPkgName, sockSpec) |
| 147 | + } |
| 148 | + const origSpec = hasOwn(depObj, origPkgName) |
| 149 | + ? depObj[origPkgName] |
| 150 | + : undefined |
| 151 | + if (origSpec) { |
| 152 | + let thisSpec = origSpec |
| 153 | + // Add package aliases for direct dependencies to avoid npm EOVERRIDE errors. |
| 154 | + // https://docs.npmjs.com/cli/v8/using-npm/package-spec#aliases |
| 155 | + if ( |
| 156 | + !( |
| 157 | + thisSpec.startsWith(sockOverridePrefix) && |
| 158 | + semver.coerce(npa(thisSpec).rawSpec)?.version |
| 159 | + ) |
| 160 | + ) { |
| 161 | + thisSpec = sockOverrideSpec |
| 162 | + depObj[origPkgName] = thisSpec |
| 163 | + state.added.add(sockRegPkgName) |
| 164 | + if (workspaceName) { |
| 165 | + state.addedInWorkspaces.add(workspaceName) |
| 166 | + } |
| 167 | + } |
| 168 | + depAliasMap.set(origPkgName, thisSpec) |
| 169 | + } |
| 170 | + } |
| 171 | + if (isRoot) { |
| 172 | + // The AgentDepsIncludesFn and AgentLockIncludesFn types overlap in their |
| 173 | + // first two parameters. AgentLockIncludesFn accepts an optional third |
| 174 | + // parameter which AgentDepsIncludesFn will ignore so we cast thingScanner |
| 175 | + // as an AgentLockIncludesFn type. |
| 176 | + const thingScanner = ( |
| 177 | + isLockScanned |
| 178 | + ? lockfileIncludesByAgent.get(agent) |
| 179 | + : depsIncludesByAgent.get(agent) |
| 180 | + ) as AgentLockIncludesFn |
| 181 | + const thingToScan = isLockScanned |
| 182 | + ? lockSrc |
| 183 | + : await lsByAgent.get(agent)!(agentExecPath, pkgPath, { npmExecPath }) |
| 184 | + // Chunk package names to process them in parallel 3 at a time. |
| 185 | + await pEach(overridesDataObjects, 3, async ({ overrides, type }) => { |
| 186 | + const overrideExists = hasOwn(overrides, origPkgName) |
| 187 | + if ( |
| 188 | + overrideExists || |
| 189 | + thingScanner(thingToScan, origPkgName, lockName) |
| 190 | + ) { |
| 191 | + const oldSpec = overrideExists ? overrides[origPkgName]! : undefined |
| 192 | + const origDepAlias = depAliasMap.get(origPkgName) |
| 193 | + const sockRegDepAlias = depAliasMap.get(sockRegPkgName) |
| 194 | + const depAlias = sockRegDepAlias ?? origDepAlias |
| 195 | + let newSpec = sockOverrideSpec |
| 196 | + if (type === NPM && depAlias) { |
| 197 | + // With npm one may not set an override for a package that one directly |
| 198 | + // depends on unless both the dependency and the override itself share |
| 199 | + // the exact same spec. To make this limitation easier to deal with, |
| 200 | + // overrides may also be defined as a reference to a spec for a direct |
| 201 | + // dependency by prefixing the name of the package to match the version |
| 202 | + // of with a $. |
| 203 | + // https://docs.npmjs.com/cli/v8/configuring-npm/package-json#overrides |
| 204 | + newSpec = `$${sockRegDepAlias ? sockRegPkgName : origPkgName}` |
| 205 | + } else if (typeof oldSpec === 'string') { |
| 206 | + const thisSpec = oldSpec.startsWith('$') |
| 207 | + ? depAlias || newSpec |
| 208 | + : oldSpec || newSpec |
| 209 | + if (thisSpec.startsWith(sockOverridePrefix)) { |
| 210 | + if ( |
| 211 | + pin && |
| 212 | + semver.major( |
| 213 | + semver.coerce(npa(thisSpec).rawSpec)?.version ?? version |
| 214 | + ) !== major |
| 215 | + ) { |
| 216 | + const otherVersion = (await fetchPackageManifest(thisSpec)) |
| 217 | + ?.version |
| 218 | + if (otherVersion && otherVersion !== version) { |
| 219 | + newSpec = `${sockOverridePrefix}${pin ? otherVersion : `^${semver.major(otherVersion)}`}` |
| 220 | + } |
| 221 | + } |
| 222 | + } else { |
| 223 | + newSpec = oldSpec |
| 224 | + } |
| 225 | + } |
| 226 | + if (newSpec !== oldSpec) { |
| 227 | + overrides[origPkgName] = newSpec |
| 228 | + const addedOrUpdated = overrideExists ? 'updated' : 'added' |
| 229 | + state[addedOrUpdated].add(sockRegPkgName) |
| 230 | + } |
| 231 | + } |
| 232 | + }) |
| 233 | + } |
| 234 | + }) |
| 235 | + |
| 236 | + if (workspaceGlobs) { |
| 237 | + const workspacePkgJsonPaths = await tinyGlob(workspaceGlobs, { |
| 238 | + absolute: true, |
| 239 | + cwd: pkgPath!, |
| 240 | + ignore: ['**/node_modules/**', '**/bower_components/**'] |
| 241 | + }) |
| 242 | + // Chunk package names to process them in parallel 3 at a time. |
| 243 | + await pEach(workspacePkgJsonPaths, 3, async workspacePkgJsonPath => { |
| 244 | + const otherState = await addOverrides( |
| 245 | + path.dirname(workspacePkgJsonPath), |
| 246 | + pkgEnvDetails, |
| 247 | + { |
| 248 | + logger, |
| 249 | + pin, |
| 250 | + prod, |
| 251 | + spinner |
| 252 | + } |
| 253 | + ) |
| 254 | + for (const key of [ |
| 255 | + 'added', |
| 256 | + 'addedInWorkspaces', |
| 257 | + 'updated', |
| 258 | + 'updatedInWorkspaces' |
| 259 | + ] satisfies |
| 260 | + // Here we're just telling TS that we're looping over key names |
| 261 | + // of the type and that they're all Set<string> props. This allows |
| 262 | + // us to do the SetA.add(setB.get) pump type-safe without casts. |
| 263 | + Array< |
| 264 | + keyof Pick< |
| 265 | + AddOverridesState, |
| 266 | + 'added' | 'addedInWorkspaces' | 'updated' | 'updatedInWorkspaces' |
| 267 | + > |
| 268 | + >) { |
| 269 | + for (const value of otherState[key]) { |
| 270 | + state[key].add(value) |
| 271 | + } |
| 272 | + } |
| 273 | + }) |
| 274 | + } |
| 275 | + |
| 276 | + if (state.added.size > 0 || state.updated.size > 0) { |
| 277 | + editablePkgJson.update(Object.fromEntries(depEntries) as PackageJson) |
| 278 | + for (const { overrides, type } of overridesDataObjects) { |
| 279 | + updateManifestByAgent.get(type)!( |
| 280 | + editablePkgJson, |
| 281 | + toSortedObject(overrides) |
| 282 | + ) |
| 283 | + } |
| 284 | + await editablePkgJson.save() |
| 285 | + } |
| 286 | + |
| 287 | + return state |
| 288 | +} |
0 commit comments