diff --git a/README.md b/README.md index 4e794539a..2224b1dfa 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,7 @@ 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/src/utils/dlx.mts b/src/utils/dlx.mts index 510836c52..57bc752a9 100644 --- a/src/utils/dlx.mts +++ b/src/utils/dlx.mts @@ -42,7 +42,10 @@ import { isYarnBerry } from './yarn-version.mts' import type { ShadowBinOptions, ShadowBinResult } from '../shadow/npm-base.mts' import type { CResult } from '../types.mts' -import type { SpawnExtra } from '@socketsecurity/registry/lib/spawn' +import type { + SpawnExtra, + SpawnOptions, +} from '@socketsecurity/registry/lib/spawn' const require = createRequire(import.meta.url) @@ -228,7 +231,10 @@ async function spawnCoanaScriptViaNode( scriptPath: string, args: string[] | readonly string[], finalEnv: NodeJS.ProcessEnv, - options: { cwd?: string | URL | undefined }, + options: { + cwd?: string | URL | undefined + stdio?: SpawnOptions['stdio'] | undefined + }, spawnExtra?: SpawnExtra | undefined, ): Promise> { const isBinary = !scriptPath.endsWith('.js') && !scriptPath.endsWith('.mjs') @@ -237,7 +243,7 @@ async function spawnCoanaScriptViaNode( const spawnResult = await spawn(isBinary ? scriptPath : 'node', spawnArgs, { cwd: options.cwd, env: sanitizeEnvForCoanaSubprocess(finalEnv), - stdio: spawnExtra?.['stdio'] || 'inherit', + stdio: options.stdio ?? spawnExtra?.['stdio'] ?? 'inherit', }) return { ok: true, data: spawnResult.stdout } @@ -322,7 +328,10 @@ async function spawnCoanaViaNpmInstall( args: string[] | readonly string[], version: string, finalEnv: NodeJS.ProcessEnv, - options: { cwd?: string | URL | undefined }, + options: { + cwd?: string | URL | undefined + stdio?: SpawnOptions['stdio'] | undefined + }, spawnExtra?: SpawnExtra | undefined, ): Promise> { let scriptPath: string @@ -350,6 +359,43 @@ async function spawnCoanaViaNpmInstall( } } +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. @@ -360,9 +406,10 @@ async function spawnCoanaViaNpmInstall( * * 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 fallback can be disabled with - * SOCKET_CLI_COANA_DISABLE_NPM_FALLBACK or forced as the primary path with - * SOCKET_CLI_COANA_FORCE_NPM_INSTALL. + * 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[], @@ -416,6 +463,18 @@ export async function spawnCoanaDlx( const resolvedVersion = coanaVersion || constants.ENV.INLINED_SOCKET_CLI_COANA_TECH_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) { @@ -424,7 +483,7 @@ export async function spawnCoanaDlx( localCoanaPath, args, finalEnv, - { cwd: dlxOptions.cwd }, + { cwd: dlxOptions.cwd, stdio: requestedStdio }, spawnExtra, ) } catch (e) { @@ -432,30 +491,20 @@ export async function spawnCoanaDlx( } } + const launcherMode = getCoanaLauncherMode() + // Allow forcing the npm-install path for debugging or for environments // where dlx is known-broken. - if (process.env['SOCKET_CLI_COANA_FORCE_NPM_INSTALL']) { + if (launcherMode === 'npm-install') { return await spawnCoanaViaNpmInstall( args, resolvedVersion, finalEnv, - { cwd: dlxOptions.cwd }, + { cwd: dlxOptions.cwd, stdio: requestedStdio }, spawnExtra, ) } - // `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". Promote the - // requested stdio into the dlx options so it is honored here too. - // `spawnCoanaScriptViaNode` already reads `spawnExtra.stdio` for the - // local-path and npm-install branches, so this aligns all three paths. - const requestedStdio = spawnExtra?.['stdio'] ?? getOwn(dlxOptions, 'stdio') - try { // Use npm/dlx version. const result = await spawnDlx( @@ -490,7 +539,7 @@ export async function spawnCoanaDlx( } catch (e) { const dlxError = buildDlxErrorResult(e) - if (process.env['SOCKET_CLI_COANA_DISABLE_NPM_FALLBACK']) { + if (launcherMode === 'npx') { return dlxError } @@ -509,7 +558,7 @@ export async function spawnCoanaDlx( args, resolvedVersion, finalEnv, - { cwd: dlxOptions.cwd }, + { cwd: dlxOptions.cwd, stdio: requestedStdio }, spawnExtra, ) if (fallbackResult.ok) { diff --git a/src/utils/dlx.test.mts b/src/utils/dlx.test.mts index 94153267c..4a219f891 100644 --- a/src/utils/dlx.test.mts +++ b/src/utils/dlx.test.mts @@ -224,6 +224,7 @@ describe('utils/dlx', () => { 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( @@ -296,6 +297,7 @@ describe('utils/dlx', () => { mockSpawn.mockReset() 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'] await fs.rm(installRoot, { recursive: true, force: true }) }) @@ -383,6 +385,99 @@ describe('utils/dlx', () => { expect(npmInstallCalls).toHaveLength(1) }) + it('skips fallback when SOCKET_CLI_COANA_LAUNCHER is npx', async () => { + process.env['SOCKET_CLI_COANA_LAUNCHER'] = 'npx' + + const result = await spawnCoanaDlx(['run', '.'], 'acme', { + coanaVersion: nextVersion(), + }) + + expect(result.ok).toBe(false) + // No npm install was attempted. + const npmInstallCalls = mockSpawn.mock.calls.filter( + ([cmd, args]) => cmd === 'npm' && (args as string[])[0] === 'install', + ) + expect(npmInstallCalls).toHaveLength(0) + }) + + it('skips dlx and goes straight to install when SOCKET_CLI_COANA_LAUNCHER is npm-install', async () => { + process.env['SOCKET_CLI_COANA_LAUNCHER'] = 'npm-install' + + const result = await spawnCoanaDlx(['run', '.'], 'acme', { + coanaVersion: nextVersion(), + }) + + expect(result.ok).toBe(true) + // dlx (any shadow bin) was never invoked. + expect(mockDlxBin).not.toHaveBeenCalled() + // npm install ran. + const npmInstallCalls = mockSpawn.mock.calls.filter( + ([cmd, args]) => cmd === 'npm' && (args as string[])[0] === 'install', + ) + expect(npmInstallCalls).toHaveLength(1) + }) + + it('prefers SOCKET_CLI_COANA_LAUNCHER over the legacy variables', async () => { + process.env['SOCKET_CLI_COANA_LAUNCHER'] = 'auto' + process.env['SOCKET_CLI_COANA_FORCE_NPM_INSTALL'] = '1' + process.env['SOCKET_CLI_COANA_DISABLE_NPM_FALLBACK'] = '1' + + const result = await spawnCoanaDlx(['run', '.'], 'acme', { + coanaVersion: nextVersion(), + }) + + // The legacy variables are ignored: dlx is still attempted (not forced + // to npm install) and the fallback still runs (not disabled). + expect(result.ok).toBe(true) + expect(mockDlxBin).toHaveBeenCalledTimes(1) + const npmInstallCalls = mockSpawn.mock.calls.filter( + ([cmd, args]) => cmd === 'npm' && (args as string[])[0] === 'install', + ) + expect(npmInstallCalls).toHaveLength(1) + }) + + it('treats an unrecognized SOCKET_CLI_COANA_LAUNCHER value as auto', async () => { + process.env['SOCKET_CLI_COANA_LAUNCHER'] = 'bogus' + + const result = await spawnCoanaDlx(['run', '.'], 'acme', { + coanaVersion: nextVersion(), + }) + + // Default behavior: dlx attempted, then the npm-install fallback. + expect(result.ok).toBe(true) + expect(mockDlxBin).toHaveBeenCalledTimes(1) + const npmInstallCalls = mockSpawn.mock.calls.filter( + ([cmd, args]) => cmd === 'npm' && (args as string[])[0] === 'install', + ) + expect(npmInstallCalls).toHaveLength(1) + }) + + it('honors options.stdio on the npm-install path', async () => { + process.env['SOCKET_CLI_COANA_LAUNCHER'] = 'npm-install' + + const result = await spawnCoanaDlx(['run', '.'], 'acme', { + coanaVersion: nextVersion(), + stdio: 'pipe', + }) + + expect(result.ok).toBe(true) + const nodeCalls = mockSpawn.mock.calls.filter(([cmd]) => cmd === 'node') + expect(nodeCalls).toHaveLength(1) + expect((nodeCalls[0]![2] as { stdio?: unknown }).stdio).toBe('pipe') + }) + + it('honors options.stdio in the auto-mode npm-install fallback', async () => { + const result = await spawnCoanaDlx(['run', '.'], 'acme', { + coanaVersion: nextVersion(), + stdio: 'pipe', + }) + + expect(result.ok).toBe(true) + const nodeCalls = mockSpawn.mock.calls.filter(([cmd]) => cmd === 'node') + expect(nodeCalls).toHaveLength(1) + expect((nodeCalls[0]![2] as { stdio?: unknown }).stdio).toBe('pipe') + }) + it('surfaces both dlx and install errors when fallback install fails', async () => { // Make npm install fail; node would not be reached. mockSpawn.mockImplementation(async (cmd: string) => { @@ -577,6 +672,7 @@ describe('utils/dlx', () => { beforeEach(() => { 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'] // The dlx launcher succeeds by default. spawnDlx picks the shadow bin by