diff --git a/.claude/skills/bump-coana/SKILL.md b/.claude/skills/bump-coana/SKILL.md index 26d0f54b2..b8e3e60e7 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 154474889..e124df240 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 fe681c9f2..5d916c80a 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 0b6f89989..93cb08a67 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 downloaded from Socket's servers instead of npm, and 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 f62e4e5b9..f859b17c7 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 c2a5fa11f..46538f523 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 dca2842d9..2e6cc1265 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 ea16b3107..a7715724c 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 4a6f29049..8a64cf5be 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 ae26aaa91..1ed7327c8 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 eafbdc02c..8df4de75b 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 c8ed5548e..d994021b3 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 f0afb2a50..34102dd43 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 0b18502f1..c5b255cbc 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 000000000..14e7354b2 --- /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 000000000..f7454f039 --- /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 90ad13f40..d8f4909ea 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 bdcaf8546..568b26fa5 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