Module: Dependabot::NpmAndYarn::Helpers

Extended by:
T::Sig
Defined in:
lib/dependabot/npm_and_yarn/helpers.rb

Overview

rubocop:disable Metrics/ModuleLength

Constant Summary collapse

YARN_PATH_NOT_FOUND =
/^.*(?<error>The "yarn-path" option has been set \(in [^)]+\), but the specified location doesn't exist)/
NPM_V11 =

NPM Version Constants

11
NPM_V10 =
10
NPM_V8 =
8
NPM_V6 =
6
NPM_DEFAULT_VERSION =
NPM_V11
PNPM_V10 =

PNPM Version Constants

10
PNPM_V9 =
9
PNPM_V8 =
8
PNPM_V7 =
7
PNPM_V6 =
6
PNPM_DEFAULT_VERSION =
PNPM_V10
PNPM_FALLBACK_VERSION =
PNPM_V6
YARN_V3 =

YARN Version Constants

3
YARN_V2 =
2
YARN_V1 =
1
YARN_DEFAULT_VERSION =
YARN_V3
YARN_FALLBACK_VERSION =
YARN_V1
SUPPORTED_COREPACK_PACKAGE_MANAGERS =

corepack supported package managers

%w(npm yarn pnpm).freeze

Class Method Summary collapse

Class Method Details

.build_corepack_env_variablesObject



596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
# File 'lib/dependabot/npm_and_yarn/helpers.rb', line 596

def self.build_corepack_env_variables
  return nil unless Dependabot::Experiments.enabled?(:enable_private_registry_for_corepack)
  return nil if dependency_files.nil? || credentials.nil?

  files = T.must(dependency_files)
  creds = T.must(credentials)

  registry_helper = RegistryHelper.new(
    {
      npmrc: files.find { |f| f.name.end_with?(".npmrc") },
      yarnrc: files.find { |f| f.name.end_with?(".yarnrc") && !f.name.end_with?(".yarnrc.yml") },
      yarnrc_yml: files.find { |f| f.name.end_with?(".yarnrc.yml") }
    },
    creds
  )

  registry_helper.find_corepack_env_variables
end

.command_observer(output) ⇒ Object



346
347
348
349
350
351
352
353
354
# File 'lib/dependabot/npm_and_yarn/helpers.rb', line 346

def self.command_observer(output)
  # Observe the output for specific error
  return {} unless output.include?("npm ERR! ERESOLVE")

  {
    gracefully_stop: true, # value must be a String
    reason: "NPM Resolution Error"
  }
end

.corepack_supported_package_manager?(name) ⇒ Boolean

Returns:

  • (Boolean)


634
635
636
# File 'lib/dependabot/npm_and_yarn/helpers.rb', line 634

def self.corepack_supported_package_manager?(name)
  SUPPORTED_COREPACK_PACKAGE_MANAGERS.include?(name)
end

.credentialsObject



38
39
40
# File 'lib/dependabot/npm_and_yarn/helpers.rb', line 38

def credentials
  T.cast(Thread.current[:npm_and_yarn_credentials], T.nilable(T::Array[Dependabot::Credential]))
end

.credentials=(creds) ⇒ Object



33
34
35
# File 'lib/dependabot/npm_and_yarn/helpers.rb', line 33

def credentials=(creds)
  Thread.current[:npm_and_yarn_credentials] = creds
end

.dependencies_with_all_versions_metadata(dependency_set) ⇒ Object



626
627
628
629
630
631
# File 'lib/dependabot/npm_and_yarn/helpers.rb', line 626

def self.(dependency_set)
  dependency_set.dependencies.map do |dependency|
    dependency.[:all_versions] = dependency_set.all_versions_for_name(dependency.name)
    dependency
  end
end

.dependency_filesObject



28
29
30
# File 'lib/dependabot/npm_and_yarn/helpers.rb', line 28

def dependency_files
  T.cast(Thread.current[:npm_and_yarn_dependency_files], T.nilable(T::Array[Dependabot::DependencyFile]))
end

.dependency_files=(files) ⇒ Object



23
24
25
# File 'lib/dependabot/npm_and_yarn/helpers.rb', line 23

def dependency_files=(files)
  Thread.current[:npm_and_yarn_dependency_files] = files
end

.fallback_to_local_version(name, env: {}) ⇒ Object



473
474
475
476
477
478
479
480
481
482
483
484
# File 'lib/dependabot/npm_and_yarn/helpers.rb', line 473

def self.fallback_to_local_version(name, env: {})
  return "Corepack does not support #{name}" unless corepack_supported_package_manager?(name)

  Dependabot.logger.info("Falling back to activate the currently installed version of #{name}.")

  # Fetch the currently installed version directly from the environment
  current_version = local_package_manager_version(name)
  Dependabot.logger.info("Activating currently installed version of #{name}: #{current_version}")

  # Prepare the existing version
  package_manager_activate(name, current_version, env: env)
end

.fetch_yarnrc_yml_value(key, default_value) ⇒ Object



144
145
146
147
148
149
150
# File 'lib/dependabot/npm_and_yarn/helpers.rb', line 144

def self.fetch_yarnrc_yml_value(key, default_value)
  if File.exist?(".yarnrc.yml") && (yarnrc = YAML.load_file(".yarnrc.yml"))
    yarnrc.fetch(key, default_value)
  else
    default_value
  end
end

.handle_subprocess_failure(error) ⇒ Object



198
199
200
201
202
203
204
205
206
207
208
209
210
# File 'lib/dependabot/npm_and_yarn/helpers.rb', line 198

def self.handle_subprocess_failure(error)
  message = error.message
  if YARN_PATH_NOT_FOUND.match?(message)
    error = T.must(T.must(YARN_PATH_NOT_FOUND.match(message))[:error]).sub(Dir.pwd, ".")
    raise MisconfiguredTooling.new("Yarn", error)
  end

  if message.include?("Internal Error") && message.include?(".yarnrc.yml")
    raise MisconfiguredTooling.new("Invalid .yarnrc.yml file", message)
  end

  raise
end

.install(name, version, env: {}) ⇒ Object



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
# File 'lib/dependabot/npm_and_yarn/helpers.rb', line 444

def self.install(name, version, env: {})
  Dependabot.logger.info("Installing \"#{name}@#{version}\"")

  begin
    # Try to activate the specified version
    output = package_manager_activate(name, version, env: env)

    # Confirm success based on the output
    if output.include?("immediate activation...")
      Dependabot.logger.info("#{name}@#{version} successfully installed.")

      Dependabot.logger.info("Activating currently installed version of #{name}: #{version}")
    else
      Dependabot.logger.error("Corepack installation output unexpected: #{output}")
      fallback_to_local_version(name, env: env)
    end
  rescue StandardError => e
    Dependabot.logger.error("Error activating #{name}@#{version}: #{e.message}")
    fallback_to_local_version(name, env: env)
  end

  # Verify the installed version
  installed_version = package_manager_version(name, env: env)

  installed_version
end

.local_package_manager_version(name) ⇒ Object



520
521
522
523
524
525
# File 'lib/dependabot/npm_and_yarn/helpers.rb', line 520

def self.local_package_manager_version(name)
  Dependabot::SharedHelpers.run_shell_command(
    "#{name} -v",
    fingerprint: "#{name} -v"
  ).strip
end

.merge_corepack_env(env) ⇒ Object



587
588
589
590
591
592
593
# File 'lib/dependabot/npm_and_yarn/helpers.rb', line 587

def self.merge_corepack_env(env)
  corepack_env = build_corepack_env_variables
  return env if corepack_env.nil? || corepack_env.empty?
  return corepack_env if env.nil?

  corepack_env.merge(env)
end

.node_versionObject



357
358
359
360
361
362
363
364
365
366
367
368
369
# File 'lib/dependabot/npm_and_yarn/helpers.rb', line 357

def self.node_version
  version = run_node_command("-v", fingerprint: "-v").strip

  # Validate the output format (e.g., "v20.18.1" or "20.18.1")
  if version.match?(/^v?\d+(\.\d+){2}$/)
    parsed_version = version.strip.delete_prefix("v") # Remove the "v" prefix if present
    Dependabot.logger.info("Using node version: #{parsed_version}")
    parsed_version
  end
rescue StandardError => e
  Dependabot.logger.error("Error retrieving Node.js version: #{e.message}")
  nil
end

.npm_version_numeric(lockfile) ⇒ Object



73
74
75
76
77
78
79
# File 'lib/dependabot/npm_and_yarn/helpers.rb', line 73

def self.npm_version_numeric(lockfile)
  detected_npm_version = detect_npm_version(lockfile)

  return NPM_DEFAULT_VERSION if detected_npm_version.nil? || detected_npm_version == NPM_V6

  detected_npm_version
end

.package_manager_activate(name, version, env: {}) ⇒ Object



507
508
509
510
511
512
513
514
515
# File 'lib/dependabot/npm_and_yarn/helpers.rb', line 507

def self.package_manager_activate(name, version, env: {})
  return "Corepack does not support #{name}" unless corepack_supported_package_manager?(name)

  Dependabot::SharedHelpers.run_shell_command(
    "corepack prepare #{name}@#{version} --activate",
    fingerprint: "corepack prepare <name>@<version> --activate",
    env: env
  ).strip
end

.package_manager_install(name, version, env: {}) ⇒ Object



495
496
497
498
499
500
501
502
503
# File 'lib/dependabot/npm_and_yarn/helpers.rb', line 495

def self.package_manager_install(name, version, env: {})
  return "Corepack does not support #{name}" unless corepack_supported_package_manager?(name)

  Dependabot::SharedHelpers.run_shell_command(
    "corepack install #{name}@#{version} --global --cache-only",
    fingerprint: "corepack install <name>@<version> --global --cache-only",
    env: env
  ).strip
end

.package_manager_run_command(name, command, fingerprint: nil, output_observer: nil, env: nil) ⇒ Object



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
# File 'lib/dependabot/npm_and_yarn/helpers.rb', line 552

def self.package_manager_run_command(
  name,
  command,
  fingerprint: nil,
  output_observer: nil,
  env: nil
)
  full_command = "corepack #{name} #{command}"
  fingerprint =  "corepack #{name} #{fingerprint || command}"

  if output_observer
    return Dependabot::SharedHelpers.run_shell_command(
      full_command,
      fingerprint: fingerprint,
      output_observer: output_observer,
      env: env
    ).strip
  else
    Dependabot::SharedHelpers.run_shell_command(
      full_command,
      fingerprint: fingerprint,
      env: env
    )
  end.strip
rescue StandardError => e
  Dependabot.logger.error("Error running package manager command: #{full_command}, Error: #{e.message}")
  if e.message.match?(/Response Code.*:.*404.*\(Not Found\)/) &&
     e.message.include?("The remote server failed to provide the requested resource")
    raise RegistryError.new(404, "The remote server failed to provide the requested resource")
  end

  raise
end

.package_manager_version(name, env: nil) ⇒ Object



529
530
531
532
533
534
535
536
537
538
539
540
# File 'lib/dependabot/npm_and_yarn/helpers.rb', line 529

def self.package_manager_version(name, env: nil)
  Dependabot.logger.info("Fetching version for package manager: #{name}")

  version = package_manager_run_command(name, "-v", env: env).strip

  Dependabot.logger.info("Installed version of #{name}: #{version}")

  version
rescue StandardError => e
  Dependabot.logger.error("Error fetching version for package manager #{name}: #{e.message}")
  raise
end

.parse_npm8?(package_lock) ⇒ Boolean

Returns:

  • (Boolean)


153
154
155
156
157
158
159
# File 'lib/dependabot/npm_and_yarn/helpers.rb', line 153

def self.parse_npm8?(package_lock)
  return true unless package_lock&.content

  detected_npm = detect_npm_version(package_lock)
  # For conversion reading properly from npm 6 lockfile we need to check if detected version is npm 6
  detected_npm.nil? || detected_npm != NPM_V6
end

.pnpm_lockfile_version(pnpm_lock) ⇒ Object



618
619
620
621
622
623
# File 'lib/dependabot/npm_and_yarn/helpers.rb', line 618

def self.pnpm_lockfile_version(pnpm_lock)
  match = T.must(pnpm_lock.content).match(/^lockfileVersion: ['"]?(?<version>[\d.]+)/)
  return match[:version] if match

  nil
end

.pnpm_version_numeric(pnpm_lock) ⇒ Object



125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
# File 'lib/dependabot/npm_and_yarn/helpers.rb', line 125

def self.pnpm_version_numeric(pnpm_lock)
  lockfile_content = pnpm_lock&.content

  return PNPM_DEFAULT_VERSION if !lockfile_content || lockfile_content.strip.empty?

  pnpm_lockfile_version_str = pnpm_lockfile_version(pnpm_lock)

  return PNPM_FALLBACK_VERSION unless pnpm_lockfile_version_str

  pnpm_lockfile_version = pnpm_lockfile_version_str.to_f

  return PNPM_V10 if pnpm_lockfile_version >= 9.0
  return PNPM_V8 if pnpm_lockfile_version >= 6.0
  return PNPM_V7 if pnpm_lockfile_version >= 5.4

  PNPM_FALLBACK_VERSION
end

.run_node_command(command, fingerprint: nil) ⇒ Object



372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
# File 'lib/dependabot/npm_and_yarn/helpers.rb', line 372

def self.run_node_command(command, fingerprint: nil)
  full_command = "node #{command}"

  Dependabot.logger.info("Running node command: #{full_command}")

  result = Dependabot::SharedHelpers.run_shell_command(
    full_command,
    fingerprint: "node #{fingerprint || command}"
  )

  Dependabot.logger.info("Command executed successfully: #{full_command}")
  result
rescue StandardError => e
  Dependabot.logger.error("Error running node command: #{full_command}, Error: #{e.message}")
  raise
end

.run_npm_command(command, fingerprint: command, env: nil) ⇒ Object



323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
# File 'lib/dependabot/npm_and_yarn/helpers.rb', line 323

def self.run_npm_command(command, fingerprint: command, env: nil)
  merged_env = merge_corepack_env(env)
  if Dependabot::Experiments.enabled?(:enable_corepack_for_npm_and_yarn)
    package_manager_run_command(
      NpmPackageManager::NAME,
      command,
      fingerprint: fingerprint,
      output_observer: ->(output) { command_observer(output) },
      env: merged_env
    )
  else
    Dependabot::SharedHelpers.run_shell_command(
      "npm #{command}",
      fingerprint: "npm #{fingerprint}",
      output_observer: ->(output) { command_observer(output) }
    )
  end
end

.run_pnpm_command(command, fingerprint: nil) ⇒ Object



404
405
406
407
408
409
410
411
412
413
# File 'lib/dependabot/npm_and_yarn/helpers.rb', line 404

def self.run_pnpm_command(command, fingerprint: nil)
  if Dependabot::Experiments.enabled?(:enable_corepack_for_npm_and_yarn)
    package_manager_run_command(PNPMPackageManager::NAME, command, fingerprint: fingerprint)
  else
    Dependabot::SharedHelpers.run_shell_command(
      "pnpm #{command}",
      fingerprint: "pnpm #{fingerprint || command}"
    )
  end
end

.run_yarn_command(command, fingerprint: nil, env: nil) ⇒ Object



397
398
399
400
# File 'lib/dependabot/npm_and_yarn/helpers.rb', line 397

def self.run_yarn_command(command, fingerprint: nil, env: nil)
  setup_yarn_berry
  run_single_yarn_command(command, fingerprint: fingerprint, env: env)
end

.run_yarn_commands(*commands) ⇒ Object



304
305
306
307
308
309
# File 'lib/dependabot/npm_and_yarn/helpers.rb', line 304

def self.run_yarn_commands(*commands)
  setup_yarn_berry
  commands.each do |cmd, fingerprint|
    run_single_yarn_command(cmd, fingerprint: fingerprint) if cmd
  end
end

.setup_yarn_berryObject



277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
# File 'lib/dependabot/npm_and_yarn/helpers.rb', line 277

def self.setup_yarn_berry
  # Always disable immutable installs so yarn's CI detection doesn't prevent updates.
  run_single_yarn_command("config set enableImmutableInstalls false")
  # Do not generate a cache if offline cache disabled. Otherwise side effects may confuse further checks
  run_single_yarn_command("config set enableGlobalCache true") unless yarn_berry_skip_build?
  # We never want to execute postinstall scripts, either set this config or mode=skip-build must be set
  run_single_yarn_command("config set enableScripts false") if yarn_berry_disable_scripts?
  if (http_proxy = ENV.fetch("HTTP_PROXY", false))
    run_single_yarn_command("config set httpProxy #{http_proxy}", fingerprint: "config set httpProxy <proxy>")
  end
  if (https_proxy = ENV.fetch("HTTPS_PROXY", false))
    run_single_yarn_command("config set httpsProxy #{https_proxy}", fingerprint: "config set httpsProxy <proxy>")
  end
  return unless (ca_file_path = ENV.fetch("NODE_EXTRA_CA_CERTS", false))

  if yarn_4_or_higher?
    run_single_yarn_command("config set httpsCaFilePath #{ca_file_path}")
  else
    run_single_yarn_command("config set caFilePath #{ca_file_path}")
  end
end

.yarn_4_or_higher?Boolean

Returns:

  • (Boolean)


247
248
249
# File 'lib/dependabot/npm_and_yarn/helpers.rb', line 247

def self.yarn_4_or_higher?
  yarn_major_version >= 4
end

.yarn_berry?(yarn_lock) ⇒ Boolean

Returns:

  • (Boolean)


162
163
164
165
166
167
168
169
# File 'lib/dependabot/npm_and_yarn/helpers.rb', line 162

def self.yarn_berry?(yarn_lock)
  return false if yarn_lock.nil? || yarn_lock.content.nil?

  yaml = YAML.safe_load(T.must(yarn_lock.content))
  yaml.key?("__metadata")
rescue StandardError
  false
end

.yarn_berry_argsObject



224
225
226
227
228
229
230
231
232
233
234
# File 'lib/dependabot/npm_and_yarn/helpers.rb', line 224

def self.yarn_berry_args
  if yarn_major_version == 2
    ""
  elsif yarn_berry_skip_build?
    "--mode=skip-build"
  else
    # We only want this mode if the cache is not being updated/managed
    # as this improperly leaves old versions in the cache
    "--mode=update-lockfile"
  end
end

.yarn_berry_disable_scripts?Boolean

Returns:

  • (Boolean)


242
243
244
# File 'lib/dependabot/npm_and_yarn/helpers.rb', line 242

def self.yarn_berry_disable_scripts?
  yarn_major_version == YARN_V2 || !yarn_zero_install?
end

.yarn_berry_skip_build?Boolean

Returns:

  • (Boolean)


237
238
239
# File 'lib/dependabot/npm_and_yarn/helpers.rb', line 237

def self.yarn_berry_skip_build?
  yarn_major_version >= YARN_V3 && (yarn_zero_install? || yarn_offline_cache?)
end

.yarn_berry_supports_minimal_age_gate?Boolean

Returns:

  • (Boolean)


252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
# File 'lib/dependabot/npm_and_yarn/helpers.rb', line 252

def self.yarn_berry_supports_minimal_age_gate?
  version = Version.new(run_single_yarn_command("--version"))
  supported = version >= Version.new("4.10.0")
  if supported
    Dependabot.logger.info(
      "Yarn #{version} supports npmMinimalAgeGate. " \
      "Setting YARN_NPM_MINIMAL_AGE_GATE=0 to bypass the release-age gate for security updates."
    )
  else
    Dependabot.logger.info(
      "Yarn #{version} does not support npmMinimalAgeGate (requires 4.10.0+). " \
      "YARN_NPM_MINIMAL_AGE_GATE will not be set."
    )
  end
  supported
rescue StandardError => e
  Dependabot.logger.warn(
    "Could not determine Yarn version to check npmMinimalAgeGate support: #{e.message}. " \
    "Assuming unsupported (returning false). YARN_NPM_MINIMAL_AGE_GATE will not be set, " \
    "so the registry's release-age gate may still block security updates."
  )
  false
end

.yarn_major_versionObject



172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
# File 'lib/dependabot/npm_and_yarn/helpers.rb', line 172

def self.yarn_major_version
  retries = 0
  output = run_single_yarn_command("--version")
  Version.new(output).major
rescue Dependabot::SharedHelpers::HelperSubprocessFailed => e
  # Should never happen, can probably be removed once this settles
  raise "Failed to replace ENV, not sure why" if T.must(retries).positive?

  message = e.message

  missing_env_var_regex = %r{Environment variable not found \((?:[^)]+)\) in #{Dir.pwd}/(?<path>\S+)}

  if message.match?(missing_env_var_regex)
    match = T.must(message.match(missing_env_var_regex))
    path = T.must(match.named_captures["path"])

    File.write(path, File.read(path).gsub(/\$\{[^}-]+\}/, ""))
    retries = T.must(retries) + 1

    retry
  end

  handle_subprocess_failure(e)
end

.yarn_offline_cache?Boolean

Returns:

  • (Boolean)


218
219
220
221
# File 'lib/dependabot/npm_and_yarn/helpers.rb', line 218

def self.yarn_offline_cache?
  yarn_cache_dir = fetch_yarnrc_yml_value("cacheFolder", ".yarn/cache")
  File.exist?(yarn_cache_dir) && (fetch_yarnrc_yml_value("nodeLinker", "") == "node-modules")
end

.yarn_version_numeric(yarn_lock) ⇒ Object



109
110
111
112
113
114
115
116
117
118
119
# File 'lib/dependabot/npm_and_yarn/helpers.rb', line 109

def self.yarn_version_numeric(yarn_lock)
  lockfile_content = yarn_lock&.content

  return YARN_DEFAULT_VERSION if lockfile_content.nil? || lockfile_content.strip.empty?

  if yarn_berry?(yarn_lock)
    YARN_DEFAULT_VERSION
  else
    YARN_FALLBACK_VERSION
  end
end

.yarn_zero_install?Boolean

Returns:

  • (Boolean)


213
214
215
# File 'lib/dependabot/npm_and_yarn/helpers.rb', line 213

def self.yarn_zero_install?
  File.exist?(".pnp.cjs")
end