Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Changed
- Generating Socket facts for a Gradle wrapper or sbt build now picks an installed JDK the pinned Gradle or sbt version runs on, when the JDK in `JAVA_HOME` or on `PATH` is one it cannot start on. A `javaHome` set in `socket.json` is used as is.

## [1.12.1](https://github.com/SocketDev/socket-cli/releases/tag/v1.12.1) - 2026-10-29

### Changed
Expand Down
4 changes: 4 additions & 0 deletions src/commands/manifest/scripts/build-tool.mts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ export function resolveBuildToolBin(
return DEFAULT_BUILD_TOOL_BIN[tool]
}

export function isBuildToolWrapper(tool: BuildTool, bin: string): boolean {
return basename(bin) === BUILD_TOOL_WRAPPER[tool]
}

// Distinct for every build sharing a directory.
export function socketFactsFileName(
tool: BuildTool,
Expand Down
270 changes: 270 additions & 0 deletions src/commands/manifest/scripts/java-home.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
import { existsSync, promises as fs } from 'node:fs'
import os from 'node:os'
import path from 'node:path'

import semver from 'semver'

import { whichBin } from '@socketsecurity/registry/lib/bin'
import { safeReadFile } from '@socketsecurity/registry/lib/fs'

import { isBuildToolWrapper, resolveBuildToolBin } from './build-tool.mts'

import type { BuildTool } from './build-tool.mts'

type JavaInstallation = { home: string; feature: number }

type JavaRange = { min: number; max: number }

type BuildJavaHome = {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[agent] Nit: current and toolVersion are only used for the log message in withBuildJavaHome. Returning just the picked installation, with the message built here, would drop this comment, the nested ternary in the log, and a test that pins the log's inputs as API.

// Undefined when no usable JDK is set in JAVA_HOME or found on PATH.
current: JavaInstallation | undefined
picked: JavaInstallation
toolVersion: string
}

type JavaSupport = {
// The first release of the tool that runs on each Java release.
runsOnJavaFrom: ReadonlyArray<[number, string]>
// The first release that needs Java 17.
needsJava17From: string
}

const JAVA_SUPPORT: Partial<Record<BuildTool, JavaSupport>> = {
// https://docs.gradle.org/current/userguide/compatibility.html
Comment on lines +32 to +33

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[agent] Nit: CLAUDE.md asks for __proto__: null on static lookup tables, as the sibling tables in build-tool.mts do. It also wants a period on every comment. Goes with the suggestion on line 67; add both to one batch.

Suggested change
const JAVA_SUPPORT: Partial<Record<BuildTool, JavaSupport>> = {
// https://docs.gradle.org/current/userguide/compatibility.html
const JAVA_SUPPORT = {
__proto__: null,
// https://docs.gradle.org/current/userguide/compatibility.html.

gradle: {
runsOnJavaFrom: [
[8, '2.0.0'],
[9, '4.3.0'],
[10, '4.7.0'],
[11, '5.0.0'],
[12, '5.4.0'],
[13, '6.0.0'],
[14, '6.3.0'],
[15, '6.7.0'],
[16, '7.0.0'],
[17, '7.3.0'],
[18, '7.5.0'],
[19, '7.6.0'],
[20, '8.3.0'],
[21, '8.5.0'],
[22, '8.8.0'],
[23, '8.10.0'],
[24, '8.14.0'],
[25, '9.1.0'],
],
needsJava17From: '9.0.0',
},
// LTS Java releases only. The sbt version is the project's, which the

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[agent] Nit: "LTS Java releases only" reads as if every LTS has a row, so a reader will wonder whether 21 was forgotten. It's covered because 21 and 25 share the same first sbt release, which may be worth saying.

// launcher downloads and runs.
sbt: {
runsOnJavaFrom: [
[11, '0.13.0'],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[agent] Nit: The table caps 0.13.x at 11, but your own measurements show 0.13.18 starting on 17. Since 1.0 to 1.2 don't, a monotonic table can't express that. It's harmless (at worst an unneeded switch to 11), but the comment could mention it so nobody "fixes" the table later.

[17, '1.3.0'],
[25, '1.9.0'],
],
needsJava17From: '2.0.0',
},
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[agent] Nit: the other half of the __proto__: null suggestion on line 32.

Suggested change
}
} as unknown as Partial<Record<BuildTool, JavaSupport>>


export function javaRange(
tool: BuildTool,
version: string,
): JavaRange | undefined {
const support = JAVA_SUPPORT[tool]
const parsed = semver.coerce(version)
if (!support || !parsed) {
return undefined
}
const { needsJava17From, runsOnJavaFrom } = support
const min = semver.gte(parsed, needsJava17From) ? 17 : 8
// Past the table's newest entry the tool may support newer Java than we know
// of, so any Java counts as supported there.
if (semver.gt(parsed, runsOnJavaFrom[runsOnJavaFrom.length - 1]![1])) {
Comment on lines +80 to +82

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[agent] Before merge: This makes every release after the newest table entry unbounded, not just new majors. Gradle 9.1.0 caps at 25, but 9.1.1 or 9.2 runs on anything, and likewise sbt 1.9.0 versus 1.9.1. Once a JDK 26 default arrives, a Gradle 9.2 project won't be moved off it, which is the failure this PR fixes. One option: only a newer major is unbounded, and later releases in the same line keep the newest known limit. The cost is an occasional unnecessary switch to an older installed JDK until the table catches up, which seems the safer direction. The test change is in the thread on java-home.test.mts:65.

Suggested change
// Past the table's newest entry the tool may support newer Java than we know
// of, so any Java counts as supported there.
if (semver.gt(parsed, runsOnJavaFrom[runsOnJavaFrom.length - 1]![1])) {
// A major release past the table's newest entry may support newer Java than
// we know of, so any Java counts as supported there.
const { 1: newest } = runsOnJavaFrom[runsOnJavaFrom.length - 1]!
if (semver.major(parsed) > semver.major(newest)) {

return { min, max: Number.POSITIVE_INFINITY }
}
const max =
runsOnJavaFrom.findLast(([, since]) => semver.gte(parsed, since))?.[0] ?? 0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[agent] Nit: CLAUDE.md asks for object notation instead of array destructuring.

Suggested change
runsOnJavaFrom.findLast(([, since]) => semver.gte(parsed, since))?.[0] ?? 0
runsOnJavaFrom.findLast(entry => semver.gte(parsed, entry[1]))?.[0] ?? 0

return { min, max }
}

// `JAVA_VERSION` in a JDK's `release` file: "1.8.0_462", "17.0.16", "25".
export function parseJavaFeatureVersion(release: string): number | undefined {
const match = /^JAVA_VERSION="?(\d+)(?:\.(\d+))?/m.exec(release)
if (!match) {
return undefined
}
const first = Number(match[1])
return first === 1 && match[2] ? Number(match[2]) : first
}

async function readJavaInstallation(
home: string,
): Promise<JavaInstallation | undefined> {
if (!existsSync(path.join(home, 'bin', 'java'))) {
return undefined
}
const release = await safeReadFile(path.join(home, 'release'))
const feature = release ? parseJavaFeatureVersion(release) : undefined
return feature === undefined ? undefined : { home, feature }
}

function defaultJavaSearchDirs(): string[] {
const userHome = os.homedir()
return [
'/usr/lib/jvm',
'/usr/lib64/jvm',
'/usr/java',
'/opt/java',
'/Library/Java/JavaVirtualMachines',
path.join(userHome, 'Library', 'Java', 'JavaVirtualMachines'),
path.join(userHome, '.sdkman', 'candidates', 'java'),
path.join(userHome, '.gradle', 'jdks'),
path.join(userHome, '.jdks'),
]
}

// Installed JDKs: the children of each search dir (macOS bundles keep theirs
// under Contents/Home), and the JAVA_HOME_<release>_<arch> variables GitHub
// and Azure runners set.
export async function discoverJavaInstallations(
env: NodeJS.ProcessEnv,
searchDirs: readonly string[] = defaultJavaSearchDirs(),
): Promise<JavaInstallation[]> {
const runnerHomes = Object.entries(env).flatMap(([name, value]) =>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[agent] Nit: same array-destructuring convention.

Suggested change
const runnerHomes = Object.entries(env).flatMap(([name, value]) =>
const runnerHomes = Object.entries(env).flatMap(({ 0: name, 1: value }) =>

value && /^JAVA_HOME_\d+_/i.test(name) ? [value] : [],
)
const searchDirHomes = await Promise.all(
searchDirs.map(async dir => {
let children: string[]
try {
children = await fs.readdir(dir)
} catch {
return []
}
return children
.sort()
.flatMap(child => [
path.join(dir, child),
path.join(dir, child, 'Contents', 'Home'),
])
Comment on lines +144 to +149

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[agent] Before merge: Gradle-provisioned JDKs mostly sit one level deeper than this looks, e.g. ~/.gradle/jdks/eclipse_adoptium-17-aarch64-os_x.2/jdk-17.0.12+7/ and oracle_corporation-21-aarch64-os_x/jdk-21.0.2.jdk/Contents/Home. Only some vendors (bellsoft, azul) unpack flat. This descends one level into children that aren't JDKs themselves. To test it, add fakeJdk(path.join(jvm, 'eclipse_adoptium-11-x64-linux', 'jdk-11.0.24+8'), '11.0.24') to the discovery test. It lands after jdk17, because the default symlink sorts first.

Suggested change
return children
.sort()
.flatMap(child => [
path.join(dir, child),
path.join(dir, child, 'Contents', 'Home'),
])
// Gradle provisions into <vendor-version-arch-os>/<jdk-x.y.z>/.
const nested = await Promise.all(
children.sort().map(async child => {
if (existsSync(path.join(dir, child, 'bin'))) {
return [child]
}
const grandchildren = await fs
.readdir(path.join(dir, child))
.catch((): string[] => [])
return [
child,
...grandchildren
.sort()
.map(grandchild => path.join(child, grandchild)),
]
}),
)
return nested
.flat()
.flatMap(child => [
path.join(dir, child),
path.join(dir, child, 'Contents', 'Home'),
])

}),
)
const installations = await Promise.all(
[...runnerHomes, ...searchDirHomes.flat()].map(async candidate => {
const installation = await readJavaInstallation(candidate)
return installation
? { ...installation, home: await fs.realpath(candidate) }
: undefined
}),
)
const seen = new Set<string>()
const found: JavaInstallation[] = []
for (const installation of installations) {
if (installation && !seen.has(installation.home)) {
seen.add(installation.home)
found.push(installation)
}
}
return found
}

// The JDK a build tool would run on: JAVA_HOME, else the `java` on PATH.
export async function currentJavaInstallation(
env: NodeJS.ProcessEnv,
): Promise<JavaInstallation | undefined> {
const javaHome = env['JAVA_HOME']
if (javaHome) {
return await readJavaInstallation(javaHome)
}
const java = await whichBin('java', { nothrow: true, path: env['PATH'] })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[agent] Before merge: whichBin rejects even with nothrow: true when nothing is found: it calls resolveBinPathSync(null) and fails with ENOENT … realpath 'null'. With JAVA_HOME unset and no java on PATH, the whole facts run aborts, in exactly the setup where discovery (e.g. JAVA_HOME_17_X64 on a runner) should have picked a JDK. A test for it: expect(await currentJavaInstallation({ PATH: path.join(root, 'empty') })).toBeUndefined().

Suggested change
const java = await whichBin('java', { nothrow: true, path: env['PATH'] })
// whichBin rejects on a miss despite nothrow.
const java = await whichBin('java', {
nothrow: true,
path: env['PATH'],
}).catch(() => undefined)

if (typeof java !== 'string') {
return undefined
}
const home = path.dirname(path.dirname(await fs.realpath(java)))
// A JDK 8 `java` on PATH can be the one under <home>/jre/bin.
return (
(await readJavaInstallation(home)) ??
(await readJavaInstallation(path.dirname(home)))
)
}

async function readProperty(
file: string,
key: string,
): Promise<string | undefined> {
const text = await safeReadFile(file)
for (const line of text?.split(/\r?\n/) ?? []) {
const match = /^\s*([^#!=:\s]+)\s*[=:]\s*(.*?)\s*$/.exec(line)
if (match?.[1] === key) {
return match[2]
}
}
return undefined
}

// The version the project pins: the Gradle wrapper's distribution, or sbt's
// `sbt.version`. Undefined for a Gradle on PATH or a custom bin.
export async function projectBuildToolVersion(
tool: BuildTool,
projectDir: string,
bin?: string | undefined,
): Promise<string | undefined> {
if (tool === 'gradle') {
const gradleBin = path.resolve(
projectDir,
resolveBuildToolBin('gradle', projectDir, bin),
)
if (!isBuildToolWrapper('gradle', gradleBin)) {
return undefined
}
const url = await readProperty(
path.join(
path.dirname(gradleBin),
'gradle',
'wrapper',
'gradle-wrapper.properties',
),
'distributionUrl',
)
return url ? /gradle-(\d+(?:\.\d+)*)[^/]*\.zip$/.exec(url)?.[1] : undefined
}
if (tool === 'sbt') {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[agent] Optional: For sbt, a custom bin still gets a pinned version, which contradicts the comment above ("Undefined for … a custom bin") and the PR description. Since JAVA_HOME is then set, invokeSbtIn starts passing --java-home to that bin, which a wrapper script or sbtn may not accept.

Suggested change
if (tool === 'sbt') {
if (tool === 'sbt' && !bin) {

return await readProperty(
path.join(projectDir, 'project', 'build.properties'),
'sbt.version',
)
}
return undefined
}

// A JDK to run the project's build tool on, when the current one is outside
// the range the tool's pinned version runs on. Undefined keeps the current JDK.
export async function selectBuildJavaHome(
tool: BuildTool,
projectDir: string,
opts: {
bin?: string | undefined
env: NodeJS.ProcessEnv
searchDirs?: readonly string[] | undefined
},
): Promise<BuildJavaHome | undefined> {
const toolVersion = await projectBuildToolVersion(tool, projectDir, opts.bin)
const range = toolVersion ? javaRange(tool, toolVersion) : undefined
if (!toolVersion || !range) {
return undefined
}
const fits = ({ feature }: JavaInstallation) =>
feature >= range.min && feature <= range.max
const current = await currentJavaInstallation(opts.env)
if (current && fits(current)) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[agent] Before merge: A current JDK we can't identify is treated as one that doesn't fit, so we switch away from a JDK that probably works, and the log says the tool "does not run on" it. On macOS without JAVA_HOME, /usr/bin/java is a launcher stub, not a symlink, so current is always undefined there and every Gradle wrapper or sbt run switches JDK. The same happens when JAVA_HOME points at a JDK without a release file. I'd switch only when the current JDK is known not to fit, or when there is no Java at all (no JAVA_HOME, nothing on PATH), and keep it when a Java exists but can't be identified. For the macOS case, /usr/libexec/java_home gives the real home.

return undefined
}
const installed = await discoverJavaInstallations(opts.env, opts.searchDirs)
let picked: JavaInstallation | undefined
for (const installation of installed) {
if (fits(installation) && installation.feature > (picked?.feature ?? 0)) {
picked = installation
}
}
return picked ? { current, picked, toolVersion } : undefined
}
Loading
Loading