From 01c64ecde0876c4cdfda0cd40a1acc7be304861f Mon Sep 17 00:00:00 2001 From: Oskar Haarklou Veileborg Date: Mon, 19 Oct 2026 11:25:09 +0200 Subject: [PATCH 1/3] feat: launch the Coana CLI from GCS instead of npm Coana releases are moving from npm to a private GCS bucket, which the Socket API hands out signed download URLs for. Socket CLI now runs Coana the way coana-package-manager's "Launching Coana" docs specify: - It looks the version up in the cache that Coana and the Socket CLIs share, through coana-cache.mjs, which is vendored from coana-package-manager and generated there. - On a miss it asks POST /orgs//coana-cli/artifacts for the cli component, downloads the signed URL while checking its size and sha256, extracts it with tar and installs it into the cache. An expired URL is renewed once. - It runs cli-wrapper.mjs with the Node running Socket CLI. A version spec that only matches releases from before the move fails with the API's explanation that those need an older Socket CLI. There is no npm fallback, so the npx, pnpm dlx, yarn dlx and npm install launch paths are gone, along with SOCKET_CLI_COANA_LAUNCHER. SOCKET_CLI_COANA_LOCAL_PATH still works. The launcher moves from dlx.mts to coana-release.mts as spawnCoana. Its npm_package_* stripping now works: the registry's spawn merged process.env back in, so the variables need to be set to undefined. --- .claude/skills/bump-coana/SKILL.md | 7 + .config/rollup.dist.config.mjs | 9 + .oxlintignore | 1 + CHANGELOG.md | 8 + README.md | 1 - biome.json | 1 + package.json | 1 + pnpm-lock.yaml | 17 +- .../coana-fix-dynamic-sbom-inference.test.mts | 16 +- src/commands/fix/coana-fix-pr-files.test.mts | 8 +- src/commands/fix/coana-fix.mts | 10 +- src/commands/fix/handle-fix-limit.test.mts | 104 +-- .../scan/perform-reachability-analysis.mts | 4 +- .../perform-reachability-analysis.test.mts | 44 +- src/utils/coana-release.mts | 468 +++++++++++++ src/utils/coana-release.test.mts | 147 ++++ src/utils/dlx.mts | 523 +-------------- src/utils/dlx.test.mts | 627 +----------------- src/vendor/coana-cache.mjs | 198 ++++++ 19 files changed, 945 insertions(+), 1249 deletions(-) create mode 100644 src/utils/coana-release.mts create mode 100644 src/utils/coana-release.test.mts create mode 100644 src/vendor/coana-cache.mjs diff --git a/.claude/skills/bump-coana/SKILL.md b/.claude/skills/bump-coana/SKILL.md index 26d0f54b21..b8e3e60e7e 100644 --- a/.claude/skills/bump-coana/SKILL.md +++ b/.claude/skills/bump-coana/SKILL.md @@ -38,6 +38,13 @@ fi 2. Find the line `const COANA_CLI_VERSION = ''` and note the version as `CURRENT_VERSION`. 3. Replace the version on that line with the new one. +Socket CLI downloads that version through the Socket API, so it has to be a +release Coana published to its GCS bucket, not one that only exists on npm. + +Don't edit `src/vendor/coana-cache.mjs`. It is generated in +coana-package-manager, and Coana's release workflow refreshes it in its own bump +PRs. + 🚨 **Do NOT touch `package.json`'s top-level `version` field.** The release workflow owns it: between releases the manifest holds the last released version, and `scripts/release/bump.mts` derives and writes the next one in-run. Hand-writing diff --git a/.config/rollup.dist.config.mjs b/.config/rollup.dist.config.mjs index 1544748895..e124df240e 100644 --- a/.config/rollup.dist.config.mjs +++ b/.config/rollup.dist.config.mjs @@ -90,6 +90,14 @@ function relativeBlessedPath(filepath) { ) } +// The cache script vendored from coana-package-manager, which coana-release.mts +// runs with `node`. +async function copyCoanaCache() { + const filepath = path.join(constants.srcPath, 'vendor/coana-cache.mjs') + const destPath = path.join(constants.distPath, 'coana-cache.mjs') + await fs.copyFile(filepath, destPath) +} + async function copyInitGradle() { const filepath = path.join(constants.srcPath, 'commands/manifest/init.gradle') const destPath = path.join(constants.distPath, 'init.gradle') @@ -534,6 +542,7 @@ export default async () => { { async writeBundle() { await Promise.all([ + copyCoanaCache(), copyInitGradle(), copyManifestScripts(), copyBashCompletion(), diff --git a/.oxlintignore b/.oxlintignore index fe681c9f21..5d916c80ac 100644 --- a/.oxlintignore +++ b/.oxlintignore @@ -1,3 +1,4 @@ package-lock.json .claude/worktrees/ .pnpm-store/ +src/vendor/coana-cache.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b6f899892..92efb34e41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ 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 +- The Coana CLI is now cached in `$XDG_CACHE_HOME/socket/coana` (`~/.cache/socket/coana` by default). + +### Removed +- The `SOCKET_CLI_COANA_LAUNCHER` environment variable no longer has an effect. + ## [1.8.4](https://github.com/SocketDev/socket-cli/releases/tag/v1.8.4) - 2026-10-16 ### Changed diff --git a/README.md b/README.md index f62e4e5b91..f859b17c7b 100644 --- a/README.md +++ b/README.md @@ -160,7 +160,6 @@ npm exec socket - `SOCKET_CLI_API_BASE_URL` - API base URL (default: `https://api.socket.dev/v0/`) - `SOCKET_CLI_API_PROXY` - Proxy for API requests (aliases: `HTTPS_PROXY`, `https_proxy`, `HTTP_PROXY`, `http_proxy`) - `SOCKET_CLI_API_TIMEOUT` - API request timeout in milliseconds -- `SOCKET_CLI_COANA_LAUNCHER` - How the reachability engine (`@coana-tech/cli`) is launched: `auto` (default; try `npx`, fall back to `npm install` + `node` if the launcher fails), `npx` (never fall back), or `npm-install` (skip `npx` entirely) - `SOCKET_CLI_DEBUG` - Enable debug logging - `DEBUG` - Enable [`debug`](https://socket.dev/npm/package/debug) package logging diff --git a/biome.json b/biome.json index c2a5fa11f7..46538f5231 100644 --- a/biome.json +++ b/biome.json @@ -16,6 +16,7 @@ "!**/.vscode", "!.claude/worktrees", "!**/coverage", + "!src/vendor/coana-cache.mjs", "!**/package.json", "!**/package-lock.json" ], diff --git a/package.json b/package.json index dca2842d9d..2e6cc12655 100644 --- a/package.json +++ b/package.json @@ -189,6 +189,7 @@ "rollup": "4.50.1", "semver": "7.7.2", "synp": "1.9.14", + "tar": "7.5.16", "taze": "19.6.0", "terminal-link": "2.1.1", "tiny-updater": "3.5.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea16b31073..a7715724c1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -389,6 +389,9 @@ importers: synp: specifier: 1.9.14 version: 1.9.14 + tar: + specifier: 7.5.16 + version: 7.5.16 taze: specifier: 19.6.0 version: 19.6.0 @@ -8129,7 +8132,7 @@ snapshots: fs-minipass@3.0.3: dependencies: - minipass: 7.1.2 + minipass: 7.1.3 fsevents@2.3.3: optional: true @@ -8625,7 +8628,7 @@ snapshots: '@npmcli/agent': 4.0.0 cacache: 20.0.1 http-cache-semantics: 4.2.0 - minipass: 7.1.2 + minipass: 7.1.3 minipass-fetch: 4.0.1 minipass-flush: 1.0.5 minipass-pipeline: 1.2.4 @@ -9030,7 +9033,7 @@ snapshots: minipass-collect@2.0.1: dependencies: - minipass: 7.1.2 + minipass: 7.1.3 minipass-fetch@4.0.1: dependencies: @@ -9074,7 +9077,7 @@ snapshots: minizlib@3.1.0: dependencies: - minipass: 7.1.2 + minipass: 7.1.3 mkdirp-classic@0.5.3: optional: true @@ -9272,7 +9275,7 @@ snapshots: '@npmcli/redact': 3.2.2 jsonparse: 1.3.1 make-fetch-happen: 15.0.2 - minipass: 7.1.2 + minipass: 7.1.3 minipass-fetch: 4.0.1 minizlib: 3.1.0 npm-package-arg: 13.0.0 @@ -9506,7 +9509,7 @@ snapshots: path-scurry@2.0.0: dependencies: lru-cache: 11.2.2 - minipass: 7.1.2 + minipass: 7.1.3 path-scurry@2.0.2: dependencies: @@ -9896,7 +9899,7 @@ snapshots: ssri@13.0.1: dependencies: - minipass: 7.1.2 + minipass: 7.1.3 stable-hash-x@0.2.0: {} diff --git a/src/commands/fix/coana-fix-dynamic-sbom-inference.test.mts b/src/commands/fix/coana-fix-dynamic-sbom-inference.test.mts index 4a6f29049f..8a64cf5be4 100644 --- a/src/commands/fix/coana-fix-dynamic-sbom-inference.test.mts +++ b/src/commands/fix/coana-fix-dynamic-sbom-inference.test.mts @@ -8,7 +8,7 @@ import { coanaFix } from './coana-fix.mts' import type { FixConfig } from './types.mts' -const mockSpawnCoanaDlx = vi.hoisted(() => vi.fn()) +const mockSpawnCoana = vi.hoisted(() => vi.fn()) const mockSetupSdk = vi.hoisted(() => vi.fn()) const mockFetchSupportedScanFileNames = vi.hoisted(() => vi.fn()) const mockGetPackageFilesForScan = vi.hoisted(() => vi.fn()) @@ -23,8 +23,8 @@ const mockGitUntrackedFiles = vi.hoisted(() => const mockGitCommit = vi.hoisted(() => vi.fn()) const mockGenerateSocketFactsForFix = vi.hoisted(() => vi.fn()) -vi.mock('../../utils/dlx.mts', () => ({ - spawnCoanaDlx: mockSpawnCoanaDlx, +vi.mock('../../utils/coana-release.mts', () => ({ + spawnCoana: mockSpawnCoana, })) vi.mock('../../utils/sdk.mts', () => ({ @@ -86,7 +86,7 @@ vi.mock('./branch-cleanup.mts', () => ({ const FACTS = '/test/cwd/app/gradle.socket.facts.json' function coanaCalls(command: string): string[][] { - return mockSpawnCoanaDlx.mock.calls + return mockSpawnCoana.mock.calls .map(call => call[0] as string[]) .filter(args => args[0] === command) } @@ -139,7 +139,7 @@ describe('socket fix --dynamic-sbom-inference', () => { mockGetFixEnv.mockResolvedValue({ isCi: false, repoInfo: null }) mockGitUnstagedModifiedFiles.mockResolvedValue({ ok: true, data: [] }) mockGitCommit.mockResolvedValue(true) - mockSpawnCoanaDlx.mockResolvedValue({ ok: true, data: '' }) + mockSpawnCoana.mockResolvedValue({ ok: true, data: '' }) }) it('uploads the generated facts, restricts Maven artifacts to them and removes them', async () => { @@ -161,7 +161,7 @@ describe('socket fix --dynamic-sbom-inference', () => { }) it('discovers vulnerabilities only through the generated facts', async () => { - mockSpawnCoanaDlx.mockImplementation(async (args: string[]) => { + mockSpawnCoana.mockImplementation(async (args: string[]) => { if (args[0] === 'find-vulnerabilities') { await fs.writeFile( args[args.indexOf('--output-file') + 1]!, @@ -249,7 +249,7 @@ describe('socket fix --dynamic-sbom-inference', () => { }) it('commits the files the fix reports writing', async () => { - mockSpawnCoanaDlx.mockImplementation(async (args: string[]) => { + mockSpawnCoana.mockImplementation(async (args: string[]) => { await fs.writeFile( args[args.indexOf('--output-file') + 1]!, JSON.stringify({ @@ -275,7 +275,7 @@ describe('socket fix --dynamic-sbom-inference', () => { }) it('commits files the fix creates', async () => { - mockSpawnCoanaDlx.mockImplementation(async (args: string[]) => { + mockSpawnCoana.mockImplementation(async (args: string[]) => { await fs.writeFile( args[args.indexOf('--output-file') + 1]!, JSON.stringify({ diff --git a/src/commands/fix/coana-fix-pr-files.test.mts b/src/commands/fix/coana-fix-pr-files.test.mts index ae26aaa917..1ed7327c8d 100644 --- a/src/commands/fix/coana-fix-pr-files.test.mts +++ b/src/commands/fix/coana-fix-pr-files.test.mts @@ -6,7 +6,7 @@ import { coanaFix } from './coana-fix.mts' import type { FixConfig } from './types.mts' -const mockSpawnCoanaDlx = vi.hoisted(() => vi.fn()) +const mockSpawnCoana = vi.hoisted(() => vi.fn()) const mockSetupSdk = vi.hoisted(() => vi.fn()) const mockFetchSupportedScanFileNames = vi.hoisted(() => vi.fn()) const mockGetPackageFilesForScan = vi.hoisted(() => vi.fn()) @@ -18,8 +18,8 @@ const mockGitUnstagedModifiedFiles = vi.hoisted(() => vi.fn()) const mockGitUntrackedFiles = vi.hoisted(() => vi.fn()) const mockGitCommit = vi.hoisted(() => vi.fn()) -vi.mock('../../utils/dlx.mts', () => ({ - spawnCoanaDlx: mockSpawnCoanaDlx, +vi.mock('../../utils/coana-release.mts', () => ({ + spawnCoana: mockSpawnCoana, })) vi.mock('../../utils/sdk.mts', () => ({ @@ -117,7 +117,7 @@ describe('socket fix PR mode commits', () => { changes: Worktree, modifiedFiles: string[] | undefined, ): void { - mockSpawnCoanaDlx.mockImplementation(async (args: string[]) => { + mockSpawnCoana.mockImplementation(async (args: string[]) => { worktree = { modified: [...new Set([...worktree.modified, ...changes.modified])], untracked: [...new Set([...worktree.untracked, ...changes.untracked])], diff --git a/src/commands/fix/coana-fix.mts b/src/commands/fix/coana-fix.mts index eafbdc02c8..8df4de75bf 100644 --- a/src/commands/fix/coana-fix.mts +++ b/src/commands/fix/coana-fix.mts @@ -28,12 +28,12 @@ import { GQL_PR_STATE_OPEN, } from '../../constants.mts' import { handleApiCall } from '../../utils/api.mts' +import { spawnCoana } from '../../utils/coana-release.mts' import { isReachabilityReportPath, isSocketFactsFile, } from '../../utils/coana.mts' import { findSocketYmlSync } from '../../utils/config.mts' -import { spawnCoanaDlx } from '../../utils/dlx.mts' import { getErrorCause } from '../../utils/errors.mts' import { withTmpDir } from '../../utils/fs.mts' import { @@ -163,7 +163,7 @@ async function discoverGhsaIds( `socket-fix-discovery-${Date.now()}.json`, ) - const foundCResult = await spawnCoanaDlx( + const foundCResult = await spawnCoana( [ 'find-vulnerabilities', cwd, @@ -182,8 +182,8 @@ async function discoverGhsaIds( cwd, spinner: silence ? undefined : spinner, coanaVersion: options?.coanaVersion, + stdio: 'pipe', }, - { stdio: 'pipe' }, ) if (!foundCResult.ok) { @@ -512,7 +512,7 @@ async function coanaFixWithFacts( const tmpFile = path.join(tmpDir, `socket-fix-${Date.now()}.json`) try { - const fixCResult = await spawnCoanaDlx( + const fixCResult = await spawnCoana( [ 'compute-fixes-and-upgrade-purls', cwd, @@ -705,7 +705,7 @@ async function coanaFixWithFacts( // Apply fix for single GHSA ID. // eslint-disable-next-line no-await-in-loop - const fixCResult = await spawnCoanaDlx( + const fixCResult = await spawnCoana( [ 'compute-fixes-and-upgrade-purls', cwd, diff --git a/src/commands/fix/handle-fix-limit.test.mts b/src/commands/fix/handle-fix-limit.test.mts index c8ed5548eb..d994021b39 100644 --- a/src/commands/fix/handle-fix-limit.test.mts +++ b/src/commands/fix/handle-fix-limit.test.mts @@ -11,7 +11,7 @@ import { coanaFix } from './coana-fix.mts' import type { FixConfig } from './types.mts' // Mock all external dependencies. -const mockSpawnCoanaDlx = vi.hoisted(() => vi.fn()) +const mockSpawnCoana = vi.hoisted(() => vi.fn()) const mockSetupSdk = vi.hoisted(() => vi.fn()) const mockFetchSupportedScanFileNames = vi.hoisted(() => vi.fn()) const mockGetPackageFilesForScan = vi.hoisted(() => vi.fn()) @@ -24,8 +24,8 @@ const mockGitUntrackedFiles = vi.hoisted(() => vi.fn(async () => ({ ok: true, data: [] })), ) -vi.mock('../../utils/dlx.mts', () => ({ - spawnCoanaDlx: mockSpawnCoanaDlx, +vi.mock('../../utils/coana-release.mts', () => ({ + spawnCoana: mockSpawnCoana, })) vi.mock('../../utils/sdk.mts', () => ({ @@ -88,7 +88,7 @@ function mockDiscoveryEnvelope(envelope: { artifactCount?: number filteredArtifactCount?: number }) { - mockSpawnCoanaDlx.mockImplementation(async (args: string[]) => { + mockSpawnCoana.mockImplementation(async (args: string[]) => { if (args[0] === 'find-vulnerabilities') { const idx = args.indexOf('--output-file') expect(idx).toBeGreaterThan(-1) @@ -110,7 +110,7 @@ function mockFixOutput( output: Record, exitCode: number | undefined, ) { - mockSpawnCoanaDlx.mockImplementation(async (args: string[]) => { + mockSpawnCoana.mockImplementation(async (args: string[]) => { await fs.writeFile( args[args.indexOf('--output-file') + 1]!, JSON.stringify(output), @@ -203,7 +203,7 @@ describe('socket fix --pr-limit behavior verification', () => { ] // Mock successful fix result. - mockSpawnCoanaDlx.mockResolvedValue({ + mockSpawnCoana.mockResolvedValue({ ok: true, data: 'fix applied', }) @@ -216,9 +216,9 @@ describe('socket fix --pr-limit behavior verification', () => { expect(result.ok).toBe(true) - // Verify spawnCoanaDlx was called once with all GHSAs (local mode has no limit). - expect(mockSpawnCoanaDlx).toHaveBeenCalledTimes(1) - const callArgs = mockSpawnCoanaDlx.mock.calls[0]?.[0] as string[] + // Verify spawnCoana was called once with all GHSAs (local mode has no limit). + expect(mockSpawnCoana).toHaveBeenCalledTimes(1) + const callArgs = mockSpawnCoana.mock.calls[0]?.[0] as string[] expect(callArgs).toContain('--apply-fixes-to') // Find the index of --apply-fixes-to and check the next arguments. @@ -240,7 +240,7 @@ describe('socket fix --pr-limit behavior verification', () => { it('should process all provided GHSAs in local mode', async () => { const ghsas = ['GHSA-1111-1111-1111', 'GHSA-2222-2222-2222'] - mockSpawnCoanaDlx.mockResolvedValue({ + mockSpawnCoana.mockResolvedValue({ ok: true, data: 'fix applied', }) @@ -251,9 +251,9 @@ describe('socket fix --pr-limit behavior verification', () => { }) expect(result.ok).toBe(true) - expect(mockSpawnCoanaDlx).toHaveBeenCalledTimes(1) + expect(mockSpawnCoana).toHaveBeenCalledTimes(1) - const callArgs = mockSpawnCoanaDlx.mock.calls[0]?.[0] as string[] + const callArgs = mockSpawnCoana.mock.calls[0]?.[0] as string[] const applyFixesIndex = callArgs.indexOf('--apply-fixes-to') const ghsaArgs = callArgs .slice(applyFixesIndex + 1) @@ -274,7 +274,7 @@ describe('socket fix --pr-limit behavior verification', () => { expect(result.data?.fixedAll).toBe(false) // Only discovery call, no fix call since no GHSAs found. - expect(mockSpawnCoanaDlx).toHaveBeenCalledTimes(1) + expect(mockSpawnCoana).toHaveBeenCalledTimes(1) }) it('should discover vulnerabilities when no GHSAs are provided', async () => { @@ -290,15 +290,15 @@ describe('socket fix --pr-limit behavior verification', () => { expect(result.ok).toBe(true) // When ghsas is empty, it first discovers vulnerabilities, then applies fixes. - expect(mockSpawnCoanaDlx).toHaveBeenCalledTimes(2) + expect(mockSpawnCoana).toHaveBeenCalledTimes(2) // First call is discovery (no --apply-fixes-to). - const discoveryArgs = mockSpawnCoanaDlx.mock.calls[0]?.[0] as string[] + const discoveryArgs = mockSpawnCoana.mock.calls[0]?.[0] as string[] expect(discoveryArgs).toContain('find-vulnerabilities') expect(discoveryArgs).not.toContain('--apply-fixes-to') // Second call applies fixes to discovered IDs. - const applyArgs = mockSpawnCoanaDlx.mock.calls[1]?.[0] as string[] + const applyArgs = mockSpawnCoana.mock.calls[1]?.[0] as string[] expect(applyArgs).toContain('--apply-fixes-to') }) }) @@ -347,7 +347,7 @@ describe('socket fix --pr-limit behavior verification', () => { expect(result.ok).toBe(true) // First call to discover vulnerabilities, then 2 calls for the fixes. - expect(mockSpawnCoanaDlx).toHaveBeenCalledTimes(3) + expect(mockSpawnCoana).toHaveBeenCalledTimes(3) }) it('should adjust prLimit based on existing open PRs', async () => { @@ -382,7 +382,7 @@ describe('socket fix --pr-limit behavior verification', () => { // With prLimit 3 and 1 existing PR, adjusted limit is 2. // So: 1 discovery call + 2 fix calls = 3 total. - expect(mockSpawnCoanaDlx).toHaveBeenCalledTimes(3) + expect(mockSpawnCoana).toHaveBeenCalledTimes(3) }) it('should process no GHSAs when existing open PRs exceed prLimit', async () => { @@ -405,13 +405,13 @@ describe('socket fix --pr-limit behavior verification', () => { expect(result.data?.fixedAll).toBe(false) // With 5 open PRs and prLimit 3, adjusted limit is 0, so no processing. - expect(mockSpawnCoanaDlx).not.toHaveBeenCalled() + expect(mockSpawnCoana).not.toHaveBeenCalled() }) }) describe('GHSA discovery failure propagation', () => { it('propagates a failed discovery spawn instead of reporting success', async () => { - mockSpawnCoanaDlx.mockResolvedValueOnce({ + mockSpawnCoana.mockResolvedValueOnce({ ok: false, message: 'Coana exited with code 1', cause: @@ -426,7 +426,7 @@ describe('socket fix --pr-limit behavior verification', () => { expect(result.ok).toBe(false) expect(result.message).toBe('Coana exited with code 1') // Discovery failed, so no fix call should follow. - expect(mockSpawnCoanaDlx).toHaveBeenCalledTimes(1) + expect(mockSpawnCoana).toHaveBeenCalledTimes(1) }) it('propagates discovery failures in PR mode', async () => { @@ -444,7 +444,7 @@ describe('socket fix --pr-limit behavior verification', () => { }) mockGetSocketFixPrs.mockResolvedValue([]) - mockSpawnCoanaDlx.mockResolvedValueOnce({ + mockSpawnCoana.mockResolvedValueOnce({ ok: false, message: 'Coana exited with code 1', }) @@ -457,7 +457,7 @@ describe('socket fix --pr-limit behavior verification', () => { expect(result.ok).toBe(false) expect(result.message).toBe('Coana exited with code 1') - expect(mockSpawnCoanaDlx).toHaveBeenCalledTimes(1) + expect(mockSpawnCoana).toHaveBeenCalledTimes(1) }) }) @@ -475,11 +475,11 @@ describe('socket fix --pr-limit behavior verification', () => { }) expect(result.ok).toBe(true) - expect(mockSpawnCoanaDlx).toHaveBeenCalledTimes(2) + expect(mockSpawnCoana).toHaveBeenCalledTimes(2) }) it('fails when Coana does not write the result file', async () => { - mockSpawnCoanaDlx.mockResolvedValueOnce({ ok: true, data: '' }) + mockSpawnCoana.mockResolvedValueOnce({ ok: true, data: '' }) const result = await coanaFix({ ...baseConfig, @@ -488,11 +488,11 @@ describe('socket fix --pr-limit behavior verification', () => { expect(result.ok).toBe(false) expect(result.message).toMatch(/did not write/i) - expect(mockSpawnCoanaDlx).toHaveBeenCalledTimes(1) + expect(mockSpawnCoana).toHaveBeenCalledTimes(1) }) it('fails when the result file is not valid JSON', async () => { - mockSpawnCoanaDlx.mockImplementation(async (args: string[]) => { + mockSpawnCoana.mockImplementation(async (args: string[]) => { const idx = args.indexOf('--output-file') await fs.writeFile(args[idx + 1]!, 'not json') return { ok: true, data: '' } @@ -508,7 +508,7 @@ describe('socket fix --pr-limit behavior verification', () => { }) it('fails when ghsaIds in the result file is not a string array', async () => { - mockSpawnCoanaDlx.mockImplementation(async (args: string[]) => { + mockSpawnCoana.mockImplementation(async (args: string[]) => { const idx = args.indexOf('--output-file') await fs.writeFile(args[idx + 1]!, JSON.stringify({ ghsaIds: [123] })) return { ok: true, data: '' } @@ -526,7 +526,7 @@ describe('socket fix --pr-limit behavior verification', () => { it.each(['null', '[1, 2]', '42', '"text"'])( 'fails cleanly when the result file is %s instead of an object', async body => { - mockSpawnCoanaDlx.mockImplementation(async (args: string[]) => { + mockSpawnCoana.mockImplementation(async (args: string[]) => { const idx = args.indexOf('--output-file') await fs.writeFile(args[idx + 1]!, body) return { ok: true, data: '' } @@ -544,7 +544,7 @@ describe('socket fix --pr-limit behavior verification', () => { it('removes the discovery temp file when the spawn fails', async () => { let outputFile = '' - mockSpawnCoanaDlx.mockImplementation(async (args: string[]) => { + mockSpawnCoana.mockImplementation(async (args: string[]) => { const idx = args.indexOf('--output-file') outputFile = args[idx + 1]! await fs.writeFile(outputFile, JSON.stringify({ ghsaIds: [] })) @@ -578,7 +578,7 @@ describe('socket fix --pr-limit behavior verification', () => { expect(result.ok).toBe(true) expect(result.data?.fixedAll).toBe(false) // Discovery succeeded with an empty list, so no fix call follows. - expect(mockSpawnCoanaDlx).toHaveBeenCalledTimes(1) + expect(mockSpawnCoana).toHaveBeenCalledTimes(1) expect(warnSpy).toHaveBeenCalledWith( expect.stringMatching(/0 artifacts/i), ) @@ -674,7 +674,7 @@ describe('socket fix --pr-limit behavior verification', () => { }) it('keeps other Coana failures as errors', async () => { - mockSpawnCoanaDlx.mockResolvedValue({ + mockSpawnCoana.mockResolvedValue({ ok: false, data: { code: 2 }, message: 'Coana command failed (exit code 2)', @@ -702,7 +702,7 @@ describe('socket fix --pr-limit behavior verification', () => { 'GHSA-5555-5555-5555', ] - mockSpawnCoanaDlx.mockResolvedValue({ + mockSpawnCoana.mockResolvedValue({ ok: true, data: 'fix applied', }) @@ -716,8 +716,8 @@ describe('socket fix --pr-limit behavior verification', () => { expect(result.ok).toBe(true) // Should process all 5 GHSAs in local mode (prLimit is ignored). - expect(mockSpawnCoanaDlx).toHaveBeenCalledTimes(1) - const callArgs = mockSpawnCoanaDlx.mock.calls[0]?.[0] as string[] + expect(mockSpawnCoana).toHaveBeenCalledTimes(1) + const callArgs = mockSpawnCoana.mock.calls[0]?.[0] as string[] const applyFixesIndex = callArgs.indexOf('--apply-fixes-to') const ghsaArgs = callArgs .slice(applyFixesIndex + 1) @@ -736,7 +736,7 @@ describe('socket fix --pr-limit behavior verification', () => { it('should handle single GHSA ID in local mode', async () => { const ghsas = ['GHSA-1111-1111-1111'] - mockSpawnCoanaDlx.mockResolvedValue({ + mockSpawnCoana.mockResolvedValue({ ok: true, data: 'fix applied', }) @@ -747,9 +747,9 @@ describe('socket fix --pr-limit behavior verification', () => { }) expect(result.ok).toBe(true) - expect(mockSpawnCoanaDlx).toHaveBeenCalledTimes(1) + expect(mockSpawnCoana).toHaveBeenCalledTimes(1) - const callArgs = mockSpawnCoanaDlx.mock.calls[0]?.[0] as string[] + const callArgs = mockSpawnCoana.mock.calls[0]?.[0] as string[] const applyFixesIndex = callArgs.indexOf('--apply-fixes-to') const ghsaArgs = callArgs .slice(applyFixesIndex + 1) @@ -761,7 +761,7 @@ describe('socket fix --pr-limit behavior verification', () => { describe('--exclude-paths flag', () => { it('passes excludePaths to getPackageFilesForScan as anchored ignore patterns', async () => { - mockSpawnCoanaDlx.mockResolvedValue({ ok: true, data: 'fix applied' }) + mockSpawnCoana.mockResolvedValue({ ok: true, data: 'fix applied' }) await coanaFix({ ...baseConfig, @@ -782,7 +782,7 @@ describe('socket fix --pr-limit behavior verification', () => { }) it('omits additionalIgnores when excludePaths is empty', async () => { - mockSpawnCoanaDlx.mockResolvedValue({ ok: true, data: 'fix applied' }) + mockSpawnCoana.mockResolvedValue({ ok: true, data: 'fix applied' }) await coanaFix({ ...baseConfig, @@ -796,7 +796,7 @@ describe('socket fix --pr-limit behavior verification', () => { }) it('forwards excludePaths to coana --exclude alongside --exclude values', async () => { - mockSpawnCoanaDlx.mockResolvedValue({ ok: true, data: 'fix applied' }) + mockSpawnCoana.mockResolvedValue({ ok: true, data: 'fix applied' }) await coanaFix({ ...baseConfig, @@ -805,8 +805,8 @@ describe('socket fix --pr-limit behavior verification', () => { ghsas: ['GHSA-1111-1111-1111'], }) - expect(mockSpawnCoanaDlx).toHaveBeenCalledTimes(1) - const callArgs = mockSpawnCoanaDlx.mock.calls[0]?.[0] as string[] + expect(mockSpawnCoana).toHaveBeenCalledTimes(1) + const callArgs = mockSpawnCoana.mock.calls[0]?.[0] as string[] const excludeIndex = callArgs.indexOf('--exclude') expect(excludeIndex).toBeGreaterThan(-1) // --exclude is followed by every pattern from both sources, in order: @@ -820,7 +820,7 @@ describe('socket fix --pr-limit behavior verification', () => { describe('--allow-overrides flag', () => { it('forwards --allow-overrides to coana in local mode', async () => { - mockSpawnCoanaDlx.mockResolvedValue({ ok: true, data: 'fix applied' }) + mockSpawnCoana.mockResolvedValue({ ok: true, data: 'fix applied' }) await coanaFix({ ...baseConfig, @@ -828,22 +828,22 @@ describe('socket fix --pr-limit behavior verification', () => { ghsas: ['GHSA-1111-1111-1111'], }) - expect(mockSpawnCoanaDlx).toHaveBeenCalledTimes(1) - const callArgs = mockSpawnCoanaDlx.mock.calls[0]?.[0] as string[] + expect(mockSpawnCoana).toHaveBeenCalledTimes(1) + const callArgs = mockSpawnCoana.mock.calls[0]?.[0] as string[] expect(callArgs[0]).toBe('compute-fixes-and-upgrade-purls') expect(callArgs).toContain('--allow-overrides') }) it('omits --allow-overrides when the flag is not set', async () => { - mockSpawnCoanaDlx.mockResolvedValue({ ok: true, data: 'fix applied' }) + mockSpawnCoana.mockResolvedValue({ ok: true, data: 'fix applied' }) await coanaFix({ ...baseConfig, ghsas: ['GHSA-1111-1111-1111'], }) - expect(mockSpawnCoanaDlx).toHaveBeenCalledTimes(1) - const callArgs = mockSpawnCoanaDlx.mock.calls[0]?.[0] as string[] + expect(mockSpawnCoana).toHaveBeenCalledTimes(1) + const callArgs = mockSpawnCoana.mock.calls[0]?.[0] as string[] expect(callArgs).not.toContain('--allow-overrides') }) @@ -862,7 +862,7 @@ describe('socket fix --pr-limit behavior verification', () => { }) mockGetSocketFixPrs.mockResolvedValue([]) mockFetchGhsaDetails.mockResolvedValue(new Map()) - mockSpawnCoanaDlx.mockResolvedValue({ ok: true, data: 'fix applied' }) + mockSpawnCoana.mockResolvedValue({ ok: true, data: 'fix applied' }) await coanaFix({ ...baseConfig, @@ -870,8 +870,8 @@ describe('socket fix --pr-limit behavior verification', () => { ghsas: ['GHSA-1111-1111-1111'], }) - expect(mockSpawnCoanaDlx).toHaveBeenCalledTimes(1) - const callArgs = mockSpawnCoanaDlx.mock.calls[0]?.[0] as string[] + expect(mockSpawnCoana).toHaveBeenCalledTimes(1) + const callArgs = mockSpawnCoana.mock.calls[0]?.[0] as string[] expect(callArgs[0]).toBe('compute-fixes-and-upgrade-purls') expect(callArgs).toContain('GHSA-1111-1111-1111') expect(callArgs).toContain('--allow-overrides') diff --git a/src/commands/scan/perform-reachability-analysis.mts b/src/commands/scan/perform-reachability-analysis.mts index f0afb2a50b..34102dd43e 100644 --- a/src/commands/scan/perform-reachability-analysis.mts +++ b/src/commands/scan/perform-reachability-analysis.mts @@ -8,11 +8,11 @@ import { logger } from '@socketsecurity/registry/lib/logger' import { isOmittedReachValue } from './reachability-units.mts' import constants, { COANA_CLI_VERSION } from '../../constants.mts' import { handleApiCall } from '../../utils/api.mts' +import { spawnCoana } from '../../utils/coana-release.mts' import { extractTier1ReachabilityScanId, isReachabilityReportPath, } from '../../utils/coana.mts' -import { spawnCoanaDlx } from '../../utils/dlx.mts' import { hasEnterpriseOrgPlan } from '../../utils/organization.mts' import { setupSdk } from '../../utils/sdk.mts' import { socketDevLink } from '../../utils/terminal-link.mts' @@ -290,7 +290,7 @@ export async function performReachabilityAnalysis( try { // Run Coana with the manifests tar hash. - const coanaResult = await spawnCoanaDlx(coanaArgs, orgSlug, { + const coanaResult = await spawnCoana(coanaArgs, orgSlug, { coanaVersion: reachabilityOptions.reachVersion, cwd, env: coanaEnv, diff --git a/src/commands/scan/perform-reachability-analysis.test.mts b/src/commands/scan/perform-reachability-analysis.test.mts index 0b18502f15..c5b255cbce 100644 --- a/src/commands/scan/perform-reachability-analysis.test.mts +++ b/src/commands/scan/perform-reachability-analysis.test.mts @@ -35,7 +35,7 @@ const { mockHandleApiCall, mockHasEnterpriseOrgPlan, mockSetupSdk, - mockSpawnCoanaDlx, + mockSpawnCoana, } = vi.hoisted(() => ({ mockFetchOrganization: vi.fn(), mockHandleApiCall: vi.fn(async () => ({ @@ -47,7 +47,7 @@ const { ok: true, data: { uploadManifestFiles: vi.fn() }, })), - mockSpawnCoanaDlx: vi.fn(), + mockSpawnCoana: vi.fn(), })) vi.mock('../organization/fetch-organization-list.mts', () => ({ @@ -58,8 +58,8 @@ vi.mock('../../utils/organization.mts', () => ({ hasEnterpriseOrgPlan: mockHasEnterpriseOrgPlan, })) -vi.mock('../../utils/dlx.mts', () => ({ - spawnCoanaDlx: mockSpawnCoanaDlx, +vi.mock('../../utils/coana-release.mts', () => ({ + spawnCoana: mockSpawnCoana, })) // Stubbed to keep the heavy SDK / API import chains out of the test. @@ -128,7 +128,7 @@ describe('performReachabilityAnalysis facts-file resolution', () => { data: { organizations: {} }, }) mockHasEnterpriseOrgPlan.mockReturnValue(true) - mockSpawnCoanaDlx.mockResolvedValue({ ok: true, data: '' }) + mockSpawnCoana.mockResolvedValue({ ok: true, data: '' }) // A scan cwd that is intentionally NOT process.cwd(). scanCwd = mkdtempSync(path.join(tmpdir(), 'socket-rea495-')) }) @@ -155,9 +155,9 @@ describe('performReachabilityAnalysis facts-file resolution', () => { target: scanCwd, }) - expect(mockSpawnCoanaDlx).toHaveBeenCalledTimes(1) + expect(mockSpawnCoana).toHaveBeenCalledTimes(1) // The Coana spawn must use the scan cwd so its write and our read agree. - expect(mockSpawnCoanaDlx.mock.calls[0]![2]).toMatchObject({ cwd: scanCwd }) + expect(mockSpawnCoana.mock.calls[0]![2]).toMatchObject({ cwd: scanCwd }) expect(result.ok).toBe(true) expect(result.ok && result.data.tier1ReachabilityScanId).toBe( @@ -198,7 +198,7 @@ describe('performReachabilityAnalysis manifests tar hash', () => { data: { organizations: {} }, }) mockHasEnterpriseOrgPlan.mockReturnValue(true) - mockSpawnCoanaDlx.mockResolvedValue({ ok: true, data: '' }) + mockSpawnCoana.mockResolvedValue({ ok: true, data: '' }) scanCwd = mkdtempSync(path.join(tmpdir(), 'socket-reatar-')) }) @@ -215,7 +215,7 @@ describe('performReachabilityAnalysis manifests tar hash', () => { target: scanCwd, }) - const args = mockSpawnCoanaDlx.mock.calls[0]![0] as string[] + const args = mockSpawnCoana.mock.calls[0]![0] as string[] expect(args).toContain('--run-without-docker') expect(args[args.indexOf('--manifests-tar-hash') + 1]).toBe(TEST_TAR_HASH) }) @@ -262,7 +262,7 @@ describe('performReachabilityAnalysis manifests tar hash', () => { }) expect(result.ok).toBe(false) - expect(mockSpawnCoanaDlx).not.toHaveBeenCalled() + expect(mockSpawnCoana).not.toHaveBeenCalled() }) }) @@ -276,7 +276,7 @@ describe('performReachabilityAnalysis timeout/memory forwarding', () => { data: { organizations: {} }, }) mockHasEnterpriseOrgPlan.mockReturnValue(true) - mockSpawnCoanaDlx.mockResolvedValue({ ok: true, data: '' }) + mockSpawnCoana.mockResolvedValue({ ok: true, data: '' }) scanCwd = mkdtempSync(path.join(tmpdir(), 'socket-reaf-')) }) @@ -294,7 +294,7 @@ describe('performReachabilityAnalysis timeout/memory forwarding', () => { reachabilityOptions: { ...makeReachabilityOptions(), ...overrides }, target: scanCwd, }) - return mockSpawnCoanaDlx.mock.calls[0]![0] as string[] + return mockSpawnCoana.mock.calls[0]![0] as string[] } it('forwards unit-bearing values to Coana verbatim', async () => { @@ -337,7 +337,7 @@ describe('performReachabilityAnalysis --maven-use-only-socket-facts gating', () data: { organizations: {} }, }) mockHasEnterpriseOrgPlan.mockReturnValue(true) - mockSpawnCoanaDlx.mockResolvedValue({ ok: true, data: '' }) + mockSpawnCoana.mockResolvedValue({ ok: true, data: '' }) scanCwd = mkdtempSync(path.join(tmpdir(), 'socket-reamvomsf-')) }) @@ -347,7 +347,7 @@ describe('performReachabilityAnalysis --maven-use-only-socket-facts gating', () it('writes an empty sidecar and still passes both flags when dynamicSbomInference is on but no build root produced one (e.g. none found, or all empty/disabled)', async () => { let sidecarContentAtSpawnTime: unknown - mockSpawnCoanaDlx.mockImplementationOnce(async (args: string[]) => { + mockSpawnCoana.mockImplementationOnce(async (args: string[]) => { const sidecarPath = args[args.indexOf('--compute-artifacts-sidecar') + 1]! sidecarContentAtSpawnTime = JSON.parse(readFileSync(sidecarPath, 'utf8')) return { ok: true, data: '' } @@ -365,7 +365,7 @@ describe('performReachabilityAnalysis --maven-use-only-socket-facts gating', () target: scanCwd, }) - const args = mockSpawnCoanaDlx.mock.calls[0]![0] as string[] + const args = mockSpawnCoana.mock.calls[0]![0] as string[] expect(args).toContain('--maven-use-only-socket-facts') expect(args).toContain('--compute-artifacts-sidecar') expect(sidecarContentAtSpawnTime).toEqual({}) @@ -389,7 +389,7 @@ describe('performReachabilityAnalysis --maven-use-only-socket-facts gating', () target: scanCwd, }) - const args = mockSpawnCoanaDlx.mock.calls[0]![0] as string[] + const args = mockSpawnCoana.mock.calls[0]![0] as string[] expect(args).toContain('--maven-use-only-socket-facts') expect(args).toContain('--compute-artifacts-sidecar') }) @@ -412,7 +412,7 @@ describe('performReachabilityAnalysis --maven-use-only-socket-facts gating', () target: scanCwd, }) - const args = mockSpawnCoanaDlx.mock.calls[0]![0] as string[] + const args = mockSpawnCoana.mock.calls[0]![0] as string[] expect(args).not.toContain('--maven-use-only-socket-facts') expect(args).toContain('--compute-artifacts-sidecar') }) @@ -428,7 +428,7 @@ describe('performReachabilityAnalysis stdio routing by output kind', () => { data: { organizations: {} }, }) mockHasEnterpriseOrgPlan.mockReturnValue(true) - mockSpawnCoanaDlx.mockResolvedValue({ ok: true, data: '' }) + mockSpawnCoana.mockResolvedValue({ ok: true, data: '' }) scanCwd = mkdtempSync(path.join(tmpdir(), 'socket-rea-stdio-')) writeFileSync( path.join(scanCwd, '.socket.facts.json'), @@ -450,7 +450,7 @@ describe('performReachabilityAnalysis stdio routing by output kind', () => { target: scanCwd, }) - expect(mockSpawnCoanaDlx.mock.calls[0]![2]).toMatchObject({ + expect(mockSpawnCoana.mock.calls[0]![2]).toMatchObject({ stdio: 'inherit', }) }) @@ -464,7 +464,7 @@ describe('performReachabilityAnalysis stdio routing by output kind', () => { target: scanCwd, }) - expect(mockSpawnCoanaDlx.mock.calls[0]![2]).toMatchObject({ + expect(mockSpawnCoana.mock.calls[0]![2]).toMatchObject({ stdio: 'inherit', }) }) @@ -479,7 +479,7 @@ describe('performReachabilityAnalysis stdio routing by output kind', () => { target: scanCwd, }) - expect(mockSpawnCoanaDlx.mock.calls[0]![2]).toMatchObject({ + expect(mockSpawnCoana.mock.calls[0]![2]).toMatchObject({ stdio: ['inherit', 2, 'inherit'], }) }) @@ -494,7 +494,7 @@ describe('performReachabilityAnalysis stdio routing by output kind', () => { target: scanCwd, }) - expect(mockSpawnCoanaDlx.mock.calls[0]![2]).toMatchObject({ + expect(mockSpawnCoana.mock.calls[0]![2]).toMatchObject({ stdio: ['inherit', 2, 'inherit'], }) }) diff --git a/src/utils/coana-release.mts b/src/utils/coana-release.mts new file mode 100644 index 0000000000..14e7354b27 --- /dev/null +++ b/src/utils/coana-release.mts @@ -0,0 +1,468 @@ +/** + * Coana CLI releases for Socket CLI. + * Runs the Coana CLI from the cache that Coana and the Socket CLIs share, and + * downloads it from the Socket API when the cache lacks it. "Launching Coana" + * in coana-package-manager's packages/cli/docs/gcs-releases.md specifies the + * steps. + * + * Key Functions: + * - spawnCoana: Run the Coana CLI for a version spec + * + * Cache: + * - Only the vendored coana-cache.mjs reads or writes it + */ + +import { createHash } from 'node:crypto' +import path from 'node:path' +import { Readable, Transform } from 'node:stream' +import { pipeline } from 'node:stream/promises' + +import semver from 'semver' +import { x as extractTar } from 'tar' + +import { debugDir, debugFn } from '@socketsecurity/registry/lib/debug' +import { logger } from '@socketsecurity/registry/lib/logger' +import { spawn } from '@socketsecurity/registry/lib/spawn' + +import { getDefaultOrgSlug } from '../commands/ci/fetch-default-org-slug.mts' +import constants, { + COANA_CLI_VERSION, + ERROR_UNABLE_RESOLVE_ORG, +} from '../constants.mts' +import { apiFetch, sendApiRequest } from './api.mts' +import { getErrorCause } from './errors.mts' +import { withTmpDir } from './fs.mts' +import { + getCliUserAgent, + getDefaultApiToken, + getDefaultProxyUrl, +} from './sdk.mts' + +import type { CResult } from '../types.mts' +import type { SpawnOptions } from '@socketsecurity/registry/lib/spawn' +import type { Spinner } from '@socketsecurity/registry/lib/spinner' + +type CoanaArtifacts = { + cli: CoanaComponent + version: string +} + +type CoanaCacheResult = { + exitCode: number + stderr: string + stdout: string +} + +type CoanaComponent = { + sha256: string + size: number + url: string +} + +type CoanaDownload = { ok: true } | { ok: false; status: number } + +type CoanaSpawnOptions = { + coanaVersion?: string | undefined + cwd?: string | URL | undefined + env?: Record | undefined + spinner?: Spinner | undefined + stdio?: SpawnOptions['stdio'] | undefined +} + +const CACHE_NOT_WRITABLE_EXIT_CODE = 3 +const COANA_CACHE_SCRIPT = 'coana-cache.mjs' +const COMPONENT = 'cli' +const NOT_CACHED_EXIT_CODE = 4 +const SHA256_REGEXP = /^[0-9a-f]{64}$/ + +function cacheResultToCResult(result: CoanaCacheResult): CResult { + if (result.exitCode === 0) { + return { ok: true, data: result.stdout } + } + return { + ok: false, + message: + result.exitCode === CACHE_NOT_WRITABLE_EXIT_CODE + ? 'Cannot write to the Coana cache' + : 'The Coana cache failed', + cause: + result.stderr || `${COANA_CACHE_SCRIPT} exited with ${result.exitCode}`, + } +} + +/** + * Build a CResult error from a thrown spawn error, preserving exit code, + * signal, and stderr context. + */ +function coanaSpawnErrorResult(e: unknown): CResult { + const stderr = (e as any)?.stderr + const stdout = (e as any)?.stdout + const exitCode = (e as any)?.code + const signal = (e as any)?.signal + const details: string[] = [] + if (typeof exitCode === 'number') { + details.push(`exit code ${exitCode}`) + } + if (signal) { + details.push(`signal ${signal}`) + } + const detailSuffix = details.length ? ` (${details.join(', ')})` : '' + // Coana logs some failures, e.g. unresolved Gradle dependencies, to stdout. + const detail = stderr || stdout || getErrorCause(e) + const message = + typeof exitCode === 'string' + ? `Failed to launch Coana${detailSuffix}: ${detail}` + : `Coana command failed${detailSuffix}: ${detail}` + return { + ok: false, + data: e, + message, + } +} + +/** + * Stream `component`'s tarball into `destDir`, checking its size and sha256 on + * the way. The tarball holds only regular files, so anything else is dropped. + */ +async function downloadComponent( + component: CoanaComponent, + destDir: string, +): Promise { + const response = await apiFetch(component.url, { + headers: { 'User-Agent': getCliUserAgent() }, + }) + if (!response.ok || !response.body) { + await response.body?.cancel() + debugFn('error', `Coana CLI download failed with ${response.status}`) + return { ok: false, status: response.status } + } + const hash = createHash('sha256') + let received = 0 + await pipeline( + Readable.from(response.body), + new Transform({ + transform(chunk: Buffer, _encoding, callback) { + received += chunk.length + if (received > component.size) { + callback( + new Error( + `The Coana CLI tarball is larger than the expected ${component.size} bytes`, + ), + ) + return + } + hash.update(chunk) + callback(null, chunk) + }, + }), + extractTar({ + cwd: destDir, + filter: (_path, entry) => + 'type' in entry && + (entry.type === 'Directory' || entry.type === 'File'), + // Run as root, tar would otherwise give the shared cache the archive's + // owners. + preserveOwner: false, + strict: true, + }), + ) + const sha256 = hash.digest('hex') + if (received !== component.size || sha256 !== component.sha256) { + throw new Error( + `The Coana CLI tarball does not match its checksum (expected ${component.size} bytes with sha256 ${component.sha256}, got ${received} bytes with sha256 ${sha256})`, + ) + } + return { ok: true } +} + +/** + * The directory of the Coana CLI's `cli` component for `versionSpec`, an exact + * version or a range, downloading it into the cache when the cache lacks it. + * `env` reaches the cache script, which reads `XDG_CACHE_HOME` from it. + */ +async function getCoanaCliDir( + versionSpec: string, + org: CResult, + env: NodeJS.ProcessEnv, + spinner: Spinner | undefined, +): Promise> { + try { + const exactVersion = semver.valid(versionSpec) + if (exactVersion) { + const cached = await lookup(exactVersion) + if (cached) { + return cached + } + } + if (!org.ok) { + // getDefaultOrgSlug puts some explanations in `data`. + const detail = + org.cause ?? (typeof org.data === 'string' ? org.data : undefined) + return { + ok: false, + message: ERROR_UNABLE_RESOLVE_ORG, + cause: `Downloading the Coana CLI ${versionSpec} needs one: ${org.message}${detail ? ` (${detail})` : ''}`, + } + } + const orgSlug = org.data + const artifacts = await requestArtifacts( + exactVersion ?? versionSpec, + orgSlug, + ) + if (!artifacts.ok) { + return artifacts + } + if (artifacts.data.version !== exactVersion) { + const cached = await lookup(artifacts.data.version) + if (cached) { + return cached + } + } + const downloading = `Downloading the Coana CLI ${artifacts.data.version}...` + const spinning = spinner?.isSpinning ? spinner : undefined + const previousText = spinning?.text + if (spinning) { + spinning.setText(downloading) + } else { + logger.info(downloading) + } + try { + return await installComponent(artifacts.data, orgSlug, env) + } finally { + spinning?.setText(previousText) + } + } catch (e) { + return { + ok: false, + message: `Failed to get the Coana CLI ${versionSpec}`, + cause: getErrorCause(e), + } + } + + /** The cached directory or a failure; `undefined` when the cache lacks it. */ + async function lookup(version: string): Promise | undefined> { + const result = await runCoanaCache(['lookup', version, COMPONENT], env) + return result.exitCode === NOT_CACHED_EXIT_CODE + ? undefined + : cacheResultToCResult(result) + } +} + +async function installComponent( + artifacts: CoanaArtifacts, + orgSlug: string, + env: NodeJS.ProcessEnv, +): Promise> { + return await withTmpDir('socket-coana-cli-', async extractDir => { + let current = artifacts + let download = await downloadComponent(current.cli, extractDir) + // A 4xx means the signed URL expired or was otherwise refused. + if (!download.ok && download.status >= 400 && download.status < 500) { + // Pin the new request to the resolved version, which a range is not. + const renewed = await requestArtifacts(current.version, orgSlug) + if (!renewed.ok) { + return renewed + } + current = renewed.data + download = await downloadComponent(current.cli, extractDir) + } + if (!download.ok) { + return { + ok: false, + message: `Failed to download the Coana CLI ${current.version}`, + cause: `The download host responded with status ${download.status}.`, + } + } + return cacheResultToCResult( + await runCoanaCache( + ['install', current.version, COMPONENT, current.cli.sha256, extractDir], + env, + ), + ) + }) +} + +function parseCoanaArtifacts(value: unknown): CoanaArtifacts | undefined { + const { components, version } = (value ?? {}) as { + components?: unknown + version?: unknown + } + const { sha256, size, url } = ((components as any)?.[COMPONENT] ?? {}) as { + sha256?: unknown + size?: unknown + url?: unknown + } + if ( + typeof version !== 'string' || + !semver.valid(version) || + typeof sha256 !== 'string' || + !SHA256_REGEXP.test(sha256) || + typeof size !== 'number' || + !Number.isSafeInteger(size) || + size <= 0 || + typeof url !== 'string' || + // apiFetch speaks only https. + !url.startsWith('https://') || + !URL.canParse(url) + ) { + return undefined + } + return { cli: { sha256, size, url }, version } +} + +async function requestArtifacts( + versionSpec: string, + orgSlug: string, +): Promise> { + const result = await sendApiRequest( + `orgs/${encodeURIComponent(orgSlug)}/coana-cli/artifacts`, + { + body: { components: [COMPONENT], version: versionSpec }, + method: 'POST', + }, + ) + if (!result.ok) { + return result + } + const artifacts = parseCoanaArtifacts(result.data) + if (!artifacts) { + debugDir('inspect', { body: result.data }) + return { + ok: false, + message: 'Socket API error', + cause: `Unexpected response to a request for the Coana CLI ${versionSpec}`, + } + } + return { ok: true, data: artifacts } +} + +/** + * Run the vendored cache script, which prints only a directory to stdout. + * Throws only when Node itself cannot be spawned. + */ +async function runCoanaCache( + args: string[], + env: NodeJS.ProcessEnv, +): Promise { + try { + const result = await spawn( + constants.execPath, + [path.join(constants.distPath, COANA_CACHE_SCRIPT), ...args], + { env, stdio: 'pipe' }, + ) + return { + exitCode: 0, + stderr: String(result.stderr).trim(), + stdout: String(result.stdout).trim(), + } + } catch (e) { + const exitCode = (e as any)?.code + if (typeof exitCode !== 'number') { + throw e + } + return { + exitCode, + stderr: String((e as any)?.stderr ?? '').trim(), + stdout: String((e as any)?.stdout ?? '').trim(), + } + } +} + +/** + * `env` without the `npm_package_*` variables that npm, pnpm and yarn classic + * set for each leaf of the cwd's package.json. Coana doesn't read them, and in + * a large monorepo they can push argv and env past ARG_MAX, so that the spawn + * fails with E2BIG. + * + * The registry's `spawn` spreads `process.env` under the env it is given, so + * they are set to `undefined`, which Node drops. + */ +function withoutNpmPackageEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const out: NodeJS.ProcessEnv = {} + for (const key of Object.keys(env)) { + out[key] = key.startsWith('npm_package_') ? undefined : env[key] + } + return out +} + +/** + * Run the Coana CLI, downloading it into the cache that Coana and the Socket + * CLIs share when the cache lacks the version. Returns a CResult with Coana's + * stdout. + * + * If SOCKET_CLI_COANA_LOCAL_PATH environment variable is set, uses the local + * Coana CLI at that path instead. + */ +export async function spawnCoana( + args: string[] | readonly string[], + orgSlug?: string, + options?: CoanaSpawnOptions | undefined, +): Promise> { + const { + coanaVersion, + cwd, + env: spawnEnv, + spinner, + stdio = 'inherit', + } = { + __proto__: null, + ...options, + } as CoanaSpawnOptions + + const mixinsEnv: Record = { + SOCKET_CLI_VERSION: constants.ENV.INLINED_SOCKET_CLI_VERSION, + // Forwarded to the Coana CLI so it can append our product token to its + // outbound axios User-Agent header. + SOCKET_CALLER_USER_AGENT: getCliUserAgent(), + } + const defaultApiToken = getDefaultApiToken() + if (defaultApiToken) { + mixinsEnv['SOCKET_CLI_API_TOKEN'] = defaultApiToken + } + + const org: CResult = orgSlug + ? { ok: true, data: orgSlug } + : await getDefaultOrgSlug() + if (org.ok) { + mixinsEnv['SOCKET_ORG_SLUG'] = org.data + } + + const proxyUrl = getDefaultProxyUrl() + if (proxyUrl) { + mixinsEnv['SOCKET_CLI_API_PROXY'] = proxyUrl + } + + const finalEnv = withoutNpmPackageEnv({ + ...process.env, + ...constants.processEnv, + ...mixinsEnv, + ...spawnEnv, + }) + + let scriptPath = process.env['SOCKET_CLI_COANA_LOCAL_PATH'] + if (!scriptPath) { + const coanaDirCResult = await getCoanaCliDir( + coanaVersion || COANA_CLI_VERSION, + org, + finalEnv, + spinner, + ) + if (!coanaDirCResult.ok) { + return coanaDirCResult + } + scriptPath = path.join(coanaDirCResult.data, 'cli-wrapper.mjs') + } + + // A local path may name a native binary. + const isBinary = !scriptPath.endsWith('.js') && !scriptPath.endsWith('.mjs') + try { + const spawnResult = await spawn( + isBinary ? scriptPath : constants.execPath, + isBinary ? args : [scriptPath, ...args], + { cwd, env: finalEnv, spinner, stdio }, + ) + return { ok: true, data: String(spawnResult.stdout) } + } catch (e) { + return coanaSpawnErrorResult(e) + } +} diff --git a/src/utils/coana-release.test.mts b/src/utils/coana-release.test.mts new file mode 100644 index 0000000000..f7454f0392 --- /dev/null +++ b/src/utils/coana-release.test.mts @@ -0,0 +1,147 @@ +/** + * Unit tests for launching the Coana CLI. + * + * Test Coverage: + * - spawnCoana: runs cli-wrapper.mjs from the cache with the caller's args, + * cwd and environment, and surfaces a failing run's output. + * + * Testing Approach: + * A fake cli-wrapper.mjs is installed into a temporary cache with the real + * coana-cache.mjs, so nothing is mocked. This needs dist/coana-cache.mjs, which + * the build copies there. + * + * Related Files: + * - utils/coana-release.mts (implementation) + * - vendor/coana-cache.mjs (the cache script) + */ + +import { promises as fs } from 'node:fs' +import os from 'node:os' +import path from 'node:path' + +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +import { spawn } from '@socketsecurity/registry/lib/spawn' + +import constants from '../constants.mts' +import { spawnCoana } from './coana-release.mts' +import { getCliUserAgent } from './sdk.mts' + +const VERSION = '1.2.3' + +// Prints what it was run with, or fails the way Coana does for some errors. +const FAKE_CLI_WRAPPER = ` +const args = process.argv.slice(2) +if (args[0] === 'fail') { + process.stdout.write('error: Could not resolve 1 dependency(ies)\\n') + process.exit(1) +} +process.stdout.write(JSON.stringify({ args, cwd: process.cwd(), env: process.env })) +` + +type FakeRun = { + args: string[] + cwd: string + env: NodeJS.ProcessEnv +} + +async function runFake( + options?: Parameters[2], +): Promise { + const result = await spawnCoana(['run', '.'], 'acme', { + coanaVersion: VERSION, + stdio: 'pipe', + ...options, + }) + if (!result.ok) { + throw new Error(`${result.message}: ${result.cause}`) + } + return JSON.parse(result.data) as FakeRun +} + +describe('spawnCoana', () => { + const savedXdgCacheHome = process.env['XDG_CACHE_HOME'] + let tmpDir: string + + beforeAll(async () => { + tmpDir = await fs.realpath( + await fs.mkdtemp(path.join(os.tmpdir(), 'socket-coana-release-')), + ) + process.env['XDG_CACHE_HOME'] = path.join(tmpDir, 'cache') + const extracted = path.join(tmpDir, 'extracted') + await fs.mkdir(extracted) + await fs.writeFile( + path.join(extracted, 'cli-wrapper.mjs'), + FAKE_CLI_WRAPPER, + ) + await spawn( + 'node', + [ + path.join(constants.distPath, 'coana-cache.mjs'), + 'install', + VERSION, + 'cli', + 'a'.repeat(64), + extracted, + ], + { stdio: 'pipe' }, + ) + }) + + afterAll(async () => { + if (savedXdgCacheHome === undefined) { + delete process.env['XDG_CACHE_HOME'] + } else { + process.env['XDG_CACHE_HOME'] = savedXdgCacheHome + } + await fs.rm(tmpDir, { force: true, recursive: true }) + }) + + it('runs the cached cli-wrapper.mjs with the args and cwd', async () => { + const run = await runFake({ cwd: tmpDir }) + + expect(run.args).toEqual(['run', '.']) + expect(run.cwd).toBe(tmpDir) + }) + + it('strips npm_package_* env vars to avoid E2BIG in big monorepos', async () => { + process.env['npm_package_dependencies_react'] = '^18.2.0' + // These carry registry and proxy settings from .npmrc. + process.env['npm_config_registry'] = 'https://artifactory.example/npm/' + try { + const { env } = await runFake() + + expect(env['npm_package_dependencies_react']).toBeUndefined() + expect(env['npm_config_registry']).toBe( + 'https://artifactory.example/npm/', + ) + } finally { + delete process.env['npm_package_dependencies_react'] + delete process.env['npm_config_registry'] + } + }) + + it('sets SOCKET_ORG_SLUG and SOCKET_CALLER_USER_AGENT, which options.env overrides', async () => { + const { env } = await runFake() + expect(env['SOCKET_ORG_SLUG']).toBe('acme') + expect(env['SOCKET_CALLER_USER_AGENT']).toBe(getCliUserAgent()) + + const overridden = await runFake({ + env: { SOCKET_CALLER_USER_AGENT: 'custom-caller-agent/1.0.0' }, + }) + expect(overridden.env['SOCKET_CALLER_USER_AGENT']).toBe( + 'custom-caller-agent/1.0.0', + ) + }) + + it('surfaces captured stdout when stderr is empty (Coana logs some failures to stdout)', async () => { + const result = await spawnCoana(['fail'], 'acme', { + coanaVersion: VERSION, + stdio: 'pipe', + }) + + expect(result.ok).toBe(false) + expect(result.message).toContain('Coana command failed (exit code 1)') + expect(result.message).toContain('Could not resolve 1 dependency(ies)') + }) +}) diff --git a/src/utils/dlx.mts b/src/utils/dlx.mts index 90ad13f40e..d8f4909eaf 100644 --- a/src/utils/dlx.mts +++ b/src/utils/dlx.mts @@ -4,7 +4,6 @@ * * Key Functions: * - spawnCdxgenDlx: Execute CycloneDX generator via dlx - * - spawnCoanaDlx: Execute Coana CLI tool via dlx * - spawnDlx: Execute packages using dlx-style commands * - spawnSynpDlx: Execute Synp converter via dlx * @@ -18,39 +17,22 @@ * - Configures environment for third-party tools */ -import { promises as fs } from 'node:fs' import { createRequire } from 'node:module' -import os from 'node:os' -import path from 'node:path' -import { logger } from '@socketsecurity/registry/lib/logger' import { getOwn } from '@socketsecurity/registry/lib/objects' -import { spawn } from '@socketsecurity/registry/lib/spawn' -import { getDefaultOrgSlug } from '../commands/ci/fetch-default-org-slug.mts' import constants, { - COANA_CLI_VERSION, FLAG_QUIET, FLAG_SILENT, NPM, PNPM, YARN, } from '../constants.mts' -import { getErrorCause } from './errors.mts' import { findUp } from './fs.mts' -import { - getCliUserAgent, - getDefaultApiToken, - getDefaultProxyUrl, -} from './sdk.mts' import { isYarnBerry } from './yarn-version.mts' import type { ShadowBinOptions, ShadowBinResult } from '../shadow/npm-base.mts' -import type { CResult } from '../types.mts' -import type { - SpawnExtra, - SpawnOptions, -} from '@socketsecurity/registry/lib/spawn' +import type { SpawnExtra } from '@socketsecurity/registry/lib/spawn' const require = createRequire(import.meta.url) @@ -196,509 +178,6 @@ export async function spawnDlx( } } -export type CoanaDlxOptions = DlxOptions & { - coanaVersion?: string | undefined -} - -/** - * Cache of resolved Coana CLI script paths from the npm-install fallback, - * keyed by version string. Lives for the lifetime of the Socket CLI process so - * repeated invocations (e.g. socket fix --pr looping per GHSA) only install - * once. - */ -const installedCoanaScriptPathsByVersion = new Map() - -/** - * Strip npm-injected `npm_package_*` env vars before spawning a Coana - * subprocess. npm (and pnpm/yarn classic) populate one env var per leaf in - * the cwd's package.json — `npm_package_dependencies_*`, `npm_package_scripts_*`, - * etc. In big monorepos with hundreds of deps this can easily account for - * 50KB+ of environment, pushing combined argv + env past Linux ARG_MAX - * (~128KB) and causing `spawn` to fail with E2BIG before Coana even starts. - * - * Coana does not read `npm_package_*` itself, so dropping them is safe. We - * intentionally keep `npm_config_*` (registry, cache, proxy settings sourced - * from .npmrc), `npm_lifecycle_*`, and everything else untouched — those can - * matter for outbound network behavior of nested `npm install` calls. - */ -function sanitizeEnvForCoanaSubprocess( - env: NodeJS.ProcessEnv, -): NodeJS.ProcessEnv { - const out: NodeJS.ProcessEnv = {} - for (const key of Object.keys(env)) { - if (key.startsWith('npm_package_')) { - continue - } - out[key] = env[key] - } - return out -} - -/** - * Spawn an installed Coana entry point via `node` (or directly, if it's a - * native binary). Shared by the SOCKET_CLI_COANA_LOCAL_PATH branch and the - * npm-install fallback. - */ -async function spawnCoanaScriptViaNode( - scriptPath: string, - args: string[] | readonly string[], - finalEnv: NodeJS.ProcessEnv, - options: { - cwd?: string | URL | undefined - stdio?: SpawnOptions['stdio'] | undefined - }, - spawnExtra?: SpawnExtra | undefined, -): Promise> { - const isBinary = !scriptPath.endsWith('.js') && !scriptPath.endsWith('.mjs') - - const spawnArgs = isBinary ? args : [scriptPath, ...args] - const spawnResult = await spawn(isBinary ? scriptPath : 'node', spawnArgs, { - cwd: options.cwd, - env: sanitizeEnvForCoanaSubprocess(finalEnv), - stdio: options.stdio ?? spawnExtra?.['stdio'] ?? 'inherit', - }) - - return { ok: true, data: spawnResult.stdout } -} - -/** - * Resolve the executable JS file inside an installed @coana-tech/cli package - * by reading its package.json `bin` field. Returns an absolute path suitable - * for passing to `node`. - */ -async function resolveCoanaBinFromInstallDir( - installDir: string, -): Promise { - const packageJsonPath = path.join( - installDir, - 'node_modules', - '@coana-tech', - 'cli', - 'package.json', - ) - const pkg = JSON.parse(await fs.readFile(packageJsonPath, 'utf8')) as { - bin?: string | Record | undefined - } - const { bin } = pkg - let relativeBin: string | undefined - if (typeof bin === 'string') { - relativeBin = bin - } else if (bin && typeof bin === 'object') { - // Prefer an entry named "coana" if present; otherwise take the first. - relativeBin = bin['coana'] ?? Object.values(bin)[0] - } - if (!relativeBin) { - throw new Error( - `@coana-tech/cli package.json at ${packageJsonPath} is missing a usable bin entry`, - ) - } - return path.resolve(path.dirname(packageJsonPath), relativeBin) -} - -/** - * Install @coana-tech/cli into a fresh temp directory via `npm install` and - * return its executable JS path. Caches the result per version for the - * lifetime of the process. - */ -async function installCoanaToTmpdir( - version: string, - finalEnv: NodeJS.ProcessEnv, -): Promise { - const cached = installedCoanaScriptPathsByVersion.get(version) - if (cached) { - return cached - } - const installDir = await fs.mkdtemp(path.join(os.tmpdir(), 'socket-coana-')) - await spawn( - 'npm', - [ - 'install', - '--no-save', - '--no-package-lock', - '--no-audit', - '--no-fund', - '--prefix', - installDir, - `@coana-tech/cli@${version}`, - ], - { - env: sanitizeEnvForCoanaSubprocess(finalEnv), - stdio: 'inherit', - }, - ) - const scriptPath = await resolveCoanaBinFromInstallDir(installDir) - installedCoanaScriptPathsByVersion.set(version, scriptPath) - return scriptPath -} - -/** - * Fallback path used when the dlx (npx / pnpm dlx / yarn dlx) invocation - * fails. Installs @coana-tech/cli into a temp directory via `npm install` - * and spawns it directly via `node`. - */ -async function spawnCoanaViaNpmInstall( - args: string[] | readonly string[], - version: string, - finalEnv: NodeJS.ProcessEnv, - options: { - cwd?: string | URL | undefined - stdio?: SpawnOptions['stdio'] | undefined - }, - spawnExtra?: SpawnExtra | undefined, -): Promise> { - let scriptPath: string - try { - scriptPath = await installCoanaToTmpdir(version, finalEnv) - } catch (e) { - const stderr = (e as any)?.stderr - const cause = getErrorCause(e) - return { - ok: false, - data: e, - message: `npm install fallback failed: ${stderr || cause}`, - } - } - try { - return await spawnCoanaScriptViaNode( - scriptPath, - args, - finalEnv, - options, - spawnExtra, - ) - } catch (e) { - return buildDlxErrorResult(e) - } -} - -type CoanaLauncherMode = 'auto' | 'npm-install' | 'npx' - -/** - * Resolve how the Coana engine should be launched. - * - * SOCKET_CLI_COANA_LAUNCHER wins when set: - * - 'auto' (default): try dlx first, fall back to `npm install` + `node` on - * launcher-level failures. - * - 'npm-install': skip dlx entirely; always `npm install` + `node`. - * - 'npx': dlx only; never fall back. - * Unrecognized values warn and behave as 'auto'. - * - * The legacy boolean variables SOCKET_CLI_COANA_FORCE_NPM_INSTALL - * ('npm-install') and SOCKET_CLI_COANA_DISABLE_NPM_FALLBACK ('npx') are still - * honored when the new variable is unset, but are intentionally undocumented. - */ -function getCoanaLauncherMode(): CoanaLauncherMode { - const rawMode = process.env['SOCKET_CLI_COANA_LAUNCHER'] - const mode = rawMode?.trim().toLowerCase() - if (mode) { - if (mode === 'auto' || mode === 'npm-install' || mode === 'npx') { - return mode - } - logger.warn( - `Ignoring unrecognized SOCKET_CLI_COANA_LAUNCHER value "${rawMode}"; expected "auto", "npm-install", or "npx".`, - ) - return 'auto' - } - if (process.env['SOCKET_CLI_COANA_FORCE_NPM_INSTALL']) { - return 'npm-install' - } - if (process.env['SOCKET_CLI_COANA_DISABLE_NPM_FALLBACK']) { - return 'npx' - } - return 'auto' -} - -/** - * Helper to spawn coana with dlx. - * Automatically uses force and silent when version is not pinned exactly. - * Returns a CResult with stdout extraction for backward compatibility. - * - * If SOCKET_CLI_COANA_LOCAL_PATH environment variable is set, uses the local - * Coana CLI at that path instead of downloading from npm. - * - * If the dlx path fails (e.g. broken `npx` on the host), falls back to - * `npm install`-ing @coana-tech/cli into a temp directory and invoking it - * directly via `node`. The launcher strategy can be overridden with - * SOCKET_CLI_COANA_LAUNCHER: 'auto' (the default) tries dlx with the - * npm-install fallback, 'npm-install' skips dlx entirely, and 'npx' never - * falls back. - */ -export async function spawnCoanaDlx( - args: string[] | readonly string[], - orgSlug?: string, - options?: CoanaDlxOptions | undefined, - spawnExtra?: SpawnExtra | undefined, -): Promise> { - const { - coanaVersion, - env: spawnEnv, - ipc, - ...dlxOptions - } = { - __proto__: null, - ...options, - } as CoanaDlxOptions - - const mixinsEnv: Record = { - SOCKET_CLI_VERSION: constants.ENV.INLINED_SOCKET_CLI_VERSION, - // Forwarded to the Coana CLI so it can append our product token to its - // outbound axios User-Agent header. - SOCKET_CALLER_USER_AGENT: getCliUserAgent(), - } - const defaultApiToken = getDefaultApiToken() - if (defaultApiToken) { - mixinsEnv['SOCKET_CLI_API_TOKEN'] = defaultApiToken - } - - if (orgSlug) { - mixinsEnv['SOCKET_ORG_SLUG'] = orgSlug - } else { - const orgSlugCResult = await getDefaultOrgSlug() - if (orgSlugCResult.ok) { - mixinsEnv['SOCKET_ORG_SLUG'] = orgSlugCResult.data - } - } - - const proxyUrl = getDefaultProxyUrl() - if (proxyUrl) { - mixinsEnv['SOCKET_CLI_API_PROXY'] = proxyUrl - } - - const finalEnv = { - ...process.env, - ...constants.processEnv, - ...mixinsEnv, - ...spawnEnv, - } - - const resolvedVersion = coanaVersion || COANA_CLI_VERSION - - // `shadowNpmBase` (the dlx launcher) configures the child's stdio from its - // `options` arg, NOT from the registry-spawn `extra` arg — the latter only - // attaches metadata to the result. Callers that requested streaming via - // `spawnExtra` (the 4th arg), e.g. `{ stdio: 'inherit' }` from - // `socket manifest gradle`, were therefore silently ignored on this path: - // Coana ran piped and its output — including the real failure reason — never - // reached the user, leaving only an unhelpful "command failed". Resolve the - // requested stdio from either argument and honor it on every launch path: - // dlx, local-path, and npm-install (e.g. `socket fix --silence` requests - // `stdio: 'pipe'` via options). - const requestedStdio = spawnExtra?.['stdio'] ?? getOwn(dlxOptions, 'stdio') - - const localCoanaPath = process.env['SOCKET_CLI_COANA_LOCAL_PATH'] - // Use local Coana CLI if path is provided. - if (localCoanaPath) { - try { - return await spawnCoanaScriptViaNode( - localCoanaPath, - args, - finalEnv, - { cwd: dlxOptions.cwd, stdio: requestedStdio }, - spawnExtra, - ) - } catch (e) { - return buildDlxErrorResult(e) - } - } - - const launcherMode = getCoanaLauncherMode() - - // Allow forcing the npm-install path for debugging or for environments - // where dlx is known-broken. - if (launcherMode === 'npm-install') { - return await spawnCoanaViaNpmInstall( - args, - resolvedVersion, - finalEnv, - { cwd: dlxOptions.cwd, stdio: requestedStdio }, - spawnExtra, - ) - } - - try { - // Use npm/dlx version. - const result = await spawnDlx( - { - name: '@coana-tech/cli', - version: resolvedVersion, - }, - args, - { - force: true, - // Do NOT silence the launcher. `--silent` (npm loglevel silent) hides - // npm's own download/registry/launch errors, so when npx/pnpm-dlx fails - // to fetch @coana-tech/cli the user is left with a bare exit code and no - // cause. shadowNpmBase defaults to `--loglevel error`, which keeps real - // launcher errors visible while staying quiet on success. - silent: false, - ...dlxOptions, - ...(requestedStdio === undefined ? {} : { stdio: requestedStdio }), - env: finalEnv, - ipc: { - [constants.SOCKET_CLI_SHADOW_ACCEPT_RISKS]: true, - [constants.SOCKET_CLI_SHADOW_API_TOKEN]: - constants.SOCKET_PUBLIC_API_TOKEN, - [constants.SOCKET_CLI_SHADOW_SILENT]: true, - ...ipc, - }, - }, - spawnExtra, - ) - const output = await result.spawnPromise - return { ok: true, data: output.stdout } - } catch (e) { - const dlxError = buildDlxErrorResult(e) - - if (launcherMode === 'npx') { - return dlxError - } - - // Only retry via `npm install` when the failure looks like the launcher - // never got Coana running. A real Coana process that booted and exited - // with an error would just hit the same failure on retry. - if (!shouldFallbackOnDlxError(e)) { - return dlxError - } - - logger.warn( - 'Coana dlx invocation failed; retrying via `npm install` + `node`.', - ) - - const fallbackResult = await spawnCoanaViaNpmInstall( - args, - resolvedVersion, - finalEnv, - { cwd: dlxOptions.cwd, stdio: requestedStdio }, - spawnExtra, - ) - if (fallbackResult.ok) { - return fallbackResult - } - // Surface both errors so support has full context. - return { - ok: false, - data: e, - message: `${dlxError.message}. npm-install fallback also failed: ${fallbackResult.message}`, - } - } -} - -/** - * Decide whether a thrown dlx error should trigger the npm-install fallback. - * - * The goal is to retry only when the dlx launcher (npx / pnpm dlx / yarn dlx) - * failed before Coana itself ran. If Coana actually booted, any subsequent - * non-zero exit is a real Coana failure and retrying would hit the same one. - * - * Signals we use, in priority order: - * 1. Captured stderr containing Coana's startup banner — definitive proof - * Coana ran, so do NOT retry. Only available when the caller passed - * `stdio: 'pipe'` (or the spawn defaulted to it). - * 2. Spawn-level errors (`e.code` is a string like 'ENOENT'): the binary - * wasn't found / couldn't start — retry. - * 3. Signal kills (`e.signal` set, or numeric `e.code >= 128`): conventionally - * not a clean exit; the customer-observed exit code 249 falls here. Retry. - * 4. Small integer exit codes with no banner in captured stderr: ambiguous, - * but Coana's own exit codes are small integers, so default to NOT retrying - * rather than blindly re-running Coana. - */ -function shouldFallbackOnDlxError(e: unknown): boolean { - // Coana clearly ran (its banner is in the captured stderr) → any later - // non-zero exit is a real Coana failure and retrying would hit it again. - if (coanaBannerSeen(e)) { - return false - } - return dlxLauncherFailedBeforeCoana(e) -} - -/** - * Heuristic: did the dlx launcher (npx / pnpm dlx / yarn dlx) fail BEFORE the - * Coana process itself started? True for spawn-level errors (a string `code` - * like ENOENT), signal kills, and exit codes >= 128 (conventionally - * signal-derived) — all cases where the launcher, not Coana, is the culprit - * (e.g. npx missing from PATH, or @coana-tech/cli failing to download). A small - * integer exit code is deliberately NOT treated as a launch failure: Coana's - * own exit codes are small integers too, so it is genuinely ambiguous. - * - * Caveat: a launcher that fails to download the package can also exit with a - * small integer (npm/npx often exit 1), which lands in the ambiguous bucket. - * We cannot disambiguate those from a real Coana exit without inspecting the - * launcher's output, so the npm-install fallback does not fire for them. - */ -function dlxLauncherFailedBeforeCoana(e: unknown): boolean { - const code = (e as any)?.code - // Spawn-level failure (e.g. ENOENT when npx is missing from PATH). - if (typeof code === 'string') { - return true - } - // Killed by signal — almost never a clean Coana exit. - if ((e as any)?.signal) { - return true - } - // Exit codes >= 128 are conventionally signal-derived, and the observed - // npx-launcher failures in the wild fall into this range (e.g. 249, 254). - return typeof code === 'number' && code >= 128 -} - -/** - * Definitive proof Coana actually booted: its startup banner appears in the - * captured stderr. Only available when the launcher's output was piped - * (captured); with inherited stdio there is nothing to inspect, so this - * returns false (the failure is then classified by exit code / signal alone). - */ -function coanaBannerSeen(e: unknown): boolean { - const capturedStderr = String((e as any)?.stderr ?? '') - return !!capturedStderr && /Coana CLI version/i.test(capturedStderr) -} - -/** - * Build a CResult error from a thrown spawn error, preserving exit code, - * signal, and stderr context. - */ -function buildDlxErrorResult(e: unknown): CResult { - const stderr = (e as any)?.stderr - const stdout = (e as any)?.stdout - const exitCode = (e as any)?.code - const signal = (e as any)?.signal - const cause = getErrorCause(e) - const details: string[] = [] - if (typeof exitCode === 'number') { - details.push(`exit code ${exitCode}`) - } - if (signal) { - details.push(`signal ${signal}`) - } - const detailSuffix = details.length ? ` (${details.join(', ')})` : '' - // Prefer captured stderr, then stdout, then the generic spawn error. Coana - // logs some failures (e.g. unresolved Gradle dependencies) to stdout, so - // without the stdout fallback a piped failure collapsed to an unhelpful - // "command failed" even when the real reason was captured. - const detail = stderr || stdout || cause - // Be honest about WHERE the failure happened. On the dlx path the spawned - // process is the package-manager launcher (npx / pnpm dlx / yarn dlx), which - // downloads @coana-tech/cli and only then runs it — so a failure may be the - // launcher dying before Coana ever started, not Coana itself. We can only be - // CERTAIN of that for a spawn-level error (a string `code` like ENOENT: the - // launcher binary could not start, so Coana provably never ran). A non-zero - // exit or signal is genuinely ambiguous — Coana may have started, streamed - // output, and then died (e.g. OOM), or the launcher may have failed to fetch - // the package — and with inherited stdio there is no captured output to tell - // them apart, so we must not assert either way. - let message: string - if (coanaBannerSeen(e)) { - message = `Coana command failed${detailSuffix}: ${detail}` - } else if (typeof (e as any)?.code === 'string') { - message = `Failed to launch Coana via the package manager${detailSuffix} — the npx/pnpm-dlx/yarn-dlx launcher could not start (e.g. it is missing from PATH): ${detail}` - } else { - message = `Coana failed to run via the package manager${detailSuffix}: ${detail}` - } - return { - ok: false, - data: e, - message, - } -} - /** * Helper to spawn cdxgen with dlx. */ diff --git a/src/utils/dlx.test.mts b/src/utils/dlx.test.mts index bdcaf85463..568b26fa54 100644 --- a/src/utils/dlx.test.mts +++ b/src/utils/dlx.test.mts @@ -1,33 +1,14 @@ -import { promises as fs } from 'node:fs' import { createRequire } from 'node:module' -import os from 'node:os' -import path from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { spawn } from '@socketsecurity/registry/lib/spawn' - import constants from '../constants.mts' -import { spawnCoanaDlx, spawnDlx } from './dlx.mts' +import { spawnDlx } from './dlx.mts' import type { DlxPackageSpec } from './dlx.mts' const require = createRequire(import.meta.url) -vi.mock('@socketsecurity/registry/lib/spawn', () => ({ - spawn: vi.fn(), -})) - -vi.mock('./sdk.mts', () => ({ - getCliUserAgent: () => 'socket-cli/1.0.0 node/v20.0.0 test/x64', - getDefaultApiToken: () => undefined, - getDefaultProxyUrl: () => undefined, -})) - -vi.mock('../commands/ci/fetch-default-org-slug.mts', () => ({ - getDefaultOrgSlug: async () => ({ ok: false }), -})) - describe('utils/dlx', () => { describe('spawnDlx', () => { let mockShadowPnpmBin: ReturnType @@ -217,610 +198,4 @@ describe('utils/dlx', () => { expect(spawnArgs[1]).toBe('@coana-tech/cli@1.0.0') }) }) - - describe('spawnCoanaDlx npm-install fallback', () => { - const mockSpawn = vi.mocked(spawn) - let mockDlxBin: ReturnType - let installRoot: string - let testCounter = 0 - - // Each test picks a unique version so they don't share the module-level - // install cache. - const nextVersion = () => `99.0.${testCounter++}` - - // Swap the shadow-bin mock to reject with a specific error shape. - // Default beforeEach uses code: 249 / stderr: 'npx aborted'. - const setDlxRejection = (err: Record) => { - mockDlxBin.mockReset() - mockDlxBin.mockImplementation(async () => { - const rejected = Promise.reject( - Object.assign(new Error('dlx exploded'), err), - ) - rejected.catch(() => {}) - return { spawnPromise: rejected } - }) - } - - beforeEach(async () => { - delete process.env['SOCKET_CLI_COANA_FORCE_NPM_INSTALL'] - delete process.env['SOCKET_CLI_COANA_DISABLE_NPM_FALLBACK'] - delete process.env['SOCKET_CLI_COANA_LAUNCHER'] - delete process.env['SOCKET_CLI_COANA_LOCAL_PATH'] - - installRoot = await fs.mkdtemp( - path.join(os.tmpdir(), 'socket-coana-test-'), - ) - - // By default, make whichever shadow bin spawnDlx auto-selects fail so - // the catch path runs. spawnDlx detects the project's PM by lockfile, so - // we mock all three (npm/pnpm/yarn) to the same failing behavior. - // Use mockImplementation so a fresh rejected promise is created per call - // and attach a no-op .catch to suppress Node's unhandled-rejection - // warning (the real handler attaches a microtask later inside the SUT). - mockDlxBin = vi.fn().mockImplementation(async () => { - const rejected = Promise.reject( - Object.assign(new Error('dlx exploded'), { - code: 249, - stderr: 'npx aborted', - }), - ) - rejected.catch(() => {}) - return { spawnPromise: rejected } - }) - for (const binPath of [ - constants.shadowNpxBinPath, - constants.shadowPnpmBinPath, - constants.shadowYarnBinPath, - ]) { - // @ts-ignore - require.cache[binPath] = { exports: mockDlxBin } - } - - // Default behavior: spawn() succeeds for both `npm install` (writing a - // realistic node_modules/@coana-tech/cli/package.json into the tmp - // install dir) and `node` (returning empty stdout). Tests override per - // case via .mockImplementationOnce. - mockSpawn.mockReset() - mockSpawn.mockImplementation(async (cmd: string, args: string[]) => { - if (cmd === 'npm' && args[0] === 'install') { - // Pull --prefix out of args to find the install dir. - const prefixIdx = args.indexOf('--prefix') - const installDir = args[prefixIdx + 1] - const pkgDir = path.join( - installDir, - 'node_modules', - '@coana-tech', - 'cli', - ) - await fs.mkdir(pkgDir, { recursive: true }) - await fs.writeFile( - path.join(pkgDir, 'package.json'), - JSON.stringify({ bin: { coana: 'dist/cli.js' } }), - ) - return { stdout: '', stderr: '' } - } - // node