diff --git a/CHANGELOG.md b/CHANGELOG.md index 74ce4b955..af0f8a9df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ 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 +- A failed Conda manifest step now stops `--auto-manifest` instead of uploading a scan that under-reports your `pip:` dependencies, matching how Gradle, Maven and sbt already behave. + +### Fixed +- `--auto-manifest` now reads whichever Conda environment file your project uses, `environment.yml` or `environment.yaml`. Repos on the `.yaml` spelling no longer need a `socket.json` workaround. +- The Conda `requirements.txt` now lands in the directory you targeted, so scans pick it up even when you run the CLI from somewhere else. + ## [1.1.179](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.179) - 2026-09-24 ### Changed diff --git a/src/commands/manifest/cmd-manifest-conda.mts b/src/commands/manifest/cmd-manifest-conda.mts index 31b148297..2617c208b 100644 --- a/src/commands/manifest/cmd-manifest-conda.mts +++ b/src/commands/manifest/cmd-manifest-conda.mts @@ -2,6 +2,7 @@ import path from 'node:path' import { logger } from '@socketsecurity/registry/lib/logger' +import { findCondaFile } from './detect-manifest-actions.mts' import { handleManifestConda } from './handle-manifest-conda.mts' import constants, { ENVIRONMENT_YAML, @@ -136,7 +137,7 @@ async function run( filename = sockJson.defaults?.manifest?.conda?.infile logger.info(`Using default --file from ${SOCKET_JSON}:`, filename) } else { - filename = ENVIRONMENT_YML + filename = findCondaFile(cwd) || ENVIRONMENT_YML } } if ( diff --git a/src/commands/manifest/detect-manifest-actions.mts b/src/commands/manifest/detect-manifest-actions.mts index e5f65e77f..4198e1729 100644 --- a/src/commands/manifest/detect-manifest-actions.mts +++ b/src/commands/manifest/detect-manifest-actions.mts @@ -19,11 +19,24 @@ export interface GeneratableManifests { cdxgen: boolean count: number conda: boolean + // The Conda file found at cwd, empty when there is none. + condaFile: string gradle: boolean maven: boolean sbt: boolean } +// Conda reads both spellings of the environment file. +export function findCondaFile(cwd: string): string { + if (existsSync(path.join(cwd, ENVIRONMENT_YML))) { + return ENVIRONMENT_YML + } + if (existsSync(path.join(cwd, ENVIRONMENT_YAML))) { + return ENVIRONMENT_YAML + } + return '' +} + export async function detectManifestActions( // Passing in null means we attempt detection for every supported language // regardless of local socket.json status. Sometimes we want that. @@ -35,6 +48,7 @@ export async function detectManifestActions( cdxgen: false, // TODO count: 0, conda: false, + condaFile: '', gradle: false, maven: false, sbt: false, @@ -102,13 +116,11 @@ export async function detectManifestActions( `[DEBUG] - conda auto-detection is disabled in ${SOCKET_JSON}`, ) } else { - const envyml = path.join(cwd, ENVIRONMENT_YML) - const hasEnvyml = existsSync(envyml) - const envyaml = path.join(cwd, ENVIRONMENT_YAML) - const hasEnvyaml = !hasEnvyml && existsSync(envyaml) - if (hasEnvyml || hasEnvyaml) { - debugLog('notice', '[DEBUG] - Detected an environment.yml Conda file') + const condaFile = findCondaFile(cwd) + if (condaFile) { + debugLog('notice', `[DEBUG] - Detected the Conda file ${condaFile}`) output.conda = true + output.condaFile = condaFile output.count += 1 } } diff --git a/src/commands/manifest/detect-manifest-actions.test.mts b/src/commands/manifest/detect-manifest-actions.test.mts index 2ae961553..950916265 100644 --- a/src/commands/manifest/detect-manifest-actions.test.mts +++ b/src/commands/manifest/detect-manifest-actions.test.mts @@ -138,3 +138,58 @@ describe('detectManifestActions — gradle detector', () => { expect(result.count).toBe(0) }) }) + +describe('detectManifestActions — conda detector', () => { + let cwd: string + + beforeEach(() => { + cwd = mkTmp() + }) + + afterEach(() => { + rmSync(cwd, { recursive: true, force: true }) + }) + + it('detects environment.yml and reports it as the conda file', async () => { + touch(cwd, 'environment.yml') + const result = await detectManifestActions(null, cwd) + expect(result.conda).toBe(true) + expect(result.condaFile).toBe('environment.yml') + expect(result.count).toBe(1) + }) + + it('detects environment.yaml and reports it as the conda file', async () => { + touch(cwd, 'environment.yaml') + const result = await detectManifestActions(null, cwd) + expect(result.conda).toBe(true) + expect(result.condaFile).toBe('environment.yaml') + expect(result.count).toBe(1) + }) + + it('prefers environment.yml when both spellings exist', async () => { + touch(cwd, 'environment.yaml') + touch(cwd, 'environment.yml') + const result = await detectManifestActions(null, cwd) + expect(result.condaFile).toBe('environment.yml') + expect(result.count).toBe(1) + }) + + it('reports no conda file when neither spelling exists', async () => { + const result = await detectManifestActions(null, cwd) + expect(result.conda).toBe(false) + expect(result.condaFile).toBe('') + }) + + it('skips conda when defaults.manifest.conda.disabled is true', async () => { + touch(cwd, 'environment.yaml') + const result = await detectManifestActions( + { + defaults: { manifest: { conda: { disabled: true } } }, + } as SocketJson, + cwd, + ) + expect(result.conda).toBe(false) + expect(result.condaFile).toBe('') + expect(result.count).toBe(0) + }) +}) diff --git a/src/commands/manifest/generate_auto_manifest.mts b/src/commands/manifest/generate_auto_manifest.mts index f063e018b..2c4325bff 100644 --- a/src/commands/manifest/generate_auto_manifest.mts +++ b/src/commands/manifest/generate_auto_manifest.mts @@ -12,7 +12,11 @@ import { handleManifestConda } from './handle-manifest-conda.mts' import { parseBuildToolOpts } from './parse-build-tool-opts.mts' import { resolveBuildToolBin } from './scripts/build-tool.mts' import { hasSidecarEntries, serializeSidecar } from './scripts/sidecar.mts' -import { REQUIREMENTS_TXT, SOCKET_JSON } from '../../constants.mts' +import { + ENVIRONMENT_YML, + REQUIREMENTS_TXT, + SOCKET_JSON, +} from '../../constants.mts' import { InputError } from '../../utils/errors.mts' import { readOrDefaultSocketJson } from '../../utils/socket-json.mts' @@ -194,16 +198,20 @@ export async function generateAutoManifest({ } if (!sockJson?.defaults?.manifest?.conda?.disabled && detected.conda) { - logger.log( - 'Detected an environment.yml file, running default Conda generator...', - ) + const condaFile = + sockJson.defaults?.manifest?.conda?.infile || + detected.condaFile || + ENVIRONMENT_YML + logger.log(`Detected ${condaFile}, running default Conda generator...`) + const beforeExitCode = process.exitCode await handleManifestConda({ cwd, - filename: sockJson.defaults?.manifest?.conda?.infile ?? 'environment.yml', + filename: condaFile, outputKind, out: sockJson.defaults?.manifest?.conda?.outfile ?? REQUIREMENTS_TXT, verbose: Boolean(sockJson.defaults?.manifest?.conda?.verbose), }) + abortManifestRunIfFailed('conda', beforeExitCode) } if (!sockJson?.defaults?.manifest?.bazel?.disabled && detected.bazel) { diff --git a/src/commands/manifest/generate_auto_manifest.test.mts b/src/commands/manifest/generate_auto_manifest.test.mts index fe2e59235..f9bd23135 100644 --- a/src/commands/manifest/generate_auto_manifest.test.mts +++ b/src/commands/manifest/generate_auto_manifest.test.mts @@ -35,6 +35,7 @@ import { extractBazelToMaven } from './bazel/extract_bazel_to_maven.mts' import { convertGradleToFacts } from './convert-gradle-to-facts.mts' import { convertGradleToMaven } from './convert_gradle_to_maven.mts' import { generateAutoManifest } from './generate_auto_manifest.mts' +import { handleManifestConda } from './handle-manifest-conda.mts' import { readOrDefaultSocketJson } from '../../utils/socket-json.mts' import type { SocketJson } from '../../utils/socket-json.mts' @@ -43,8 +44,10 @@ const baseDetected = { bazel: false, cdxgen: false, conda: false, + condaFile: '', count: 0, gradle: false, + maven: false, sbt: false, } @@ -302,3 +305,85 @@ describe('generateAutoManifest — bazel branch', () => { ) }) }) + +describe('generateAutoManifest — conda branch', () => { + beforeEach(() => { + vi.mocked(handleManifestConda).mockClear() + vi.mocked(readOrDefaultSocketJson).mockReturnValue({} as SocketJson) + process.exitCode = undefined + }) + + it('reads the detected environment.yaml rather than the .yml default', async () => { + await generateAutoManifest({ + cwd: '/tmp/repo', + detected: { + ...baseDetected, + conda: true, + condaFile: 'environment.yaml', + count: 1, + }, + outputKind: 'text', + verbose: false, + }) + expect(handleManifestConda).toHaveBeenCalledWith( + expect.objectContaining({ filename: 'environment.yaml' }), + ) + }) + + it('reads the detected environment.yml', async () => { + await generateAutoManifest({ + cwd: '/tmp/repo', + detected: { + ...baseDetected, + conda: true, + condaFile: 'environment.yml', + count: 1, + }, + outputKind: 'text', + verbose: false, + }) + expect(handleManifestConda).toHaveBeenCalledWith( + expect.objectContaining({ filename: 'environment.yml' }), + ) + }) + + it('lets a socket.json infile win over the detected file', async () => { + vi.mocked(readOrDefaultSocketJson).mockReturnValue({ + defaults: { manifest: { conda: { infile: 'conda-env.yml' } } }, + } as SocketJson) + await generateAutoManifest({ + cwd: '/tmp/repo', + detected: { + ...baseDetected, + conda: true, + condaFile: 'environment.yaml', + count: 1, + }, + outputKind: 'text', + verbose: false, + }) + expect(handleManifestConda).toHaveBeenCalledWith( + expect.objectContaining({ filename: 'conda-env.yml' }), + ) + }) + + it('aborts the run when the conda generator fails', async () => { + vi.mocked(handleManifestConda).mockImplementationOnce(async () => { + process.exitCode = 1 + }) + await expect( + generateAutoManifest({ + cwd: '/tmp/repo', + detected: { + ...baseDetected, + conda: true, + condaFile: 'environment.yaml', + count: 1, + }, + outputKind: 'text', + verbose: false, + }), + ).rejects.toThrow(/Auto-manifest generation failed for the conda project/) + process.exitCode = undefined + }) +}) diff --git a/src/commands/manifest/handle-manifest-conda.mts b/src/commands/manifest/handle-manifest-conda.mts index 7b0ef8991..15608576c 100644 --- a/src/commands/manifest/handle-manifest-conda.mts +++ b/src/commands/manifest/handle-manifest-conda.mts @@ -1,3 +1,5 @@ +import path from 'node:path' + import { convertCondaToRequirements } from './convert-conda-to-requirements.mts' import { outputRequirements } from './output-requirements.mts' @@ -18,5 +20,11 @@ export async function handleManifestConda({ }): Promise { const data = await convertCondaToRequirements(filename, cwd, verbose) - await outputRequirements(data, outputKind, out) + // --auto-manifest only collects the generated file when it lands inside + // the dir being scanned. + await outputRequirements( + data, + outputKind, + out === '-' ? out : path.resolve(cwd, out), + ) }