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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
97 changes: 73 additions & 24 deletions src/utils/dlx.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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<CResult<string>> {
const isBinary = !scriptPath.endsWith('.js') && !scriptPath.endsWith('.mjs')
Expand All @@ -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 }
Expand Down Expand Up @@ -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<CResult<string>> {
let scriptPath: string
Expand Down Expand Up @@ -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.
Expand All @@ -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[],
Expand Down Expand Up @@ -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) {
Expand All @@ -424,38 +483,28 @@ export async function spawnCoanaDlx(
localCoanaPath,
args,
finalEnv,
{ cwd: dlxOptions.cwd },
{ 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 (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(
Expand Down Expand Up @@ -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
}

Expand All @@ -509,7 +558,7 @@ export async function spawnCoanaDlx(
args,
resolvedVersion,
finalEnv,
{ cwd: dlxOptions.cwd },
{ cwd: dlxOptions.cwd, stdio: requestedStdio },
spawnExtra,
)
if (fallbackResult.ok) {
Expand Down
96 changes: 96 additions & 0 deletions src/utils/dlx.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 })
})

Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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
Expand Down
Loading