Skip to content

Commit 597f620

Browse files
authored
Consolidate Coana launcher env vars into SOCKET_CLI_COANA_LAUNCHER (#1360)
* feat(reach): consolidate Coana launcher env vars into SOCKET_CLI_COANA_LAUNCHER The npm-install launcher path could previously be tuned via two boolean env vars: SOCKET_CLI_COANA_FORCE_NPM_INSTALL (skip npx, always npm install + node) and SOCKET_CLI_COANA_DISABLE_NPM_FALLBACK (npx only, never fall back). They really express three modes of one setting, so replace them with a single SOCKET_CLI_COANA_LAUNCHER variable taking auto (default), npx, or npm-install. The legacy variables remain supported for backward compatibility when the new variable is unset, but are intentionally left undocumented. Unrecognized values warn and behave as auto. Follow-up from the review discussion on SocketDev/socket-python-cli#230; the Python CLI is getting the same change. * fix(reach): honor requested stdio on all Coana launch paths The dlx branch resolves the caller's requested stdio from both the options and spawnExtra arguments, but spawnCoanaScriptViaNode only read spawnExtra. The local-path, forced npm-install, and auto-mode fallback branches therefore dropped stdio passed via options and defaulted to inherit — `socket fix --silence` requests stdio 'pipe' via options, so those paths leaked Coana output to the terminal. Resolve the requested stdio once and thread it through every launch path.
1 parent 7e9d316 commit 597f620

3 files changed

Lines changed: 170 additions & 24 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ npm exec socket
110110
- `SOCKET_CLI_API_BASE_URL` - API base URL (default: `https://api.socket.dev/v0/`)
111111
- `SOCKET_CLI_API_PROXY` - Proxy for API requests (aliases: `HTTPS_PROXY`, `https_proxy`, `HTTP_PROXY`, `http_proxy`)
112112
- `SOCKET_CLI_API_TIMEOUT` - API request timeout in milliseconds
113+
- `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)
113114
- `SOCKET_CLI_DEBUG` - Enable debug logging
114115
- `DEBUG` - Enable [`debug`](https://socket.dev/npm/package/debug) package logging
115116

src/utils/dlx.mts

Lines changed: 73 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,10 @@ import { isYarnBerry } from './yarn-version.mts'
4242

4343
import type { ShadowBinOptions, ShadowBinResult } from '../shadow/npm-base.mts'
4444
import type { CResult } from '../types.mts'
45-
import type { SpawnExtra } from '@socketsecurity/registry/lib/spawn'
45+
import type {
46+
SpawnExtra,
47+
SpawnOptions,
48+
} from '@socketsecurity/registry/lib/spawn'
4649

4750
const require = createRequire(import.meta.url)
4851

@@ -228,7 +231,10 @@ async function spawnCoanaScriptViaNode(
228231
scriptPath: string,
229232
args: string[] | readonly string[],
230233
finalEnv: NodeJS.ProcessEnv,
231-
options: { cwd?: string | URL | undefined },
234+
options: {
235+
cwd?: string | URL | undefined
236+
stdio?: SpawnOptions['stdio'] | undefined
237+
},
232238
spawnExtra?: SpawnExtra | undefined,
233239
): Promise<CResult<string>> {
234240
const isBinary = !scriptPath.endsWith('.js') && !scriptPath.endsWith('.mjs')
@@ -237,7 +243,7 @@ async function spawnCoanaScriptViaNode(
237243
const spawnResult = await spawn(isBinary ? scriptPath : 'node', spawnArgs, {
238244
cwd: options.cwd,
239245
env: sanitizeEnvForCoanaSubprocess(finalEnv),
240-
stdio: spawnExtra?.['stdio'] || 'inherit',
246+
stdio: options.stdio ?? spawnExtra?.['stdio'] ?? 'inherit',
241247
})
242248

243249
return { ok: true, data: spawnResult.stdout }
@@ -322,7 +328,10 @@ async function spawnCoanaViaNpmInstall(
322328
args: string[] | readonly string[],
323329
version: string,
324330
finalEnv: NodeJS.ProcessEnv,
325-
options: { cwd?: string | URL | undefined },
331+
options: {
332+
cwd?: string | URL | undefined
333+
stdio?: SpawnOptions['stdio'] | undefined
334+
},
326335
spawnExtra?: SpawnExtra | undefined,
327336
): Promise<CResult<string>> {
328337
let scriptPath: string
@@ -350,6 +359,43 @@ async function spawnCoanaViaNpmInstall(
350359
}
351360
}
352361

362+
type CoanaLauncherMode = 'auto' | 'npm-install' | 'npx'
363+
364+
/**
365+
* Resolve how the Coana engine should be launched.
366+
*
367+
* SOCKET_CLI_COANA_LAUNCHER wins when set:
368+
* - 'auto' (default): try dlx first, fall back to `npm install` + `node` on
369+
* launcher-level failures.
370+
* - 'npm-install': skip dlx entirely; always `npm install` + `node`.
371+
* - 'npx': dlx only; never fall back.
372+
* Unrecognized values warn and behave as 'auto'.
373+
*
374+
* The legacy boolean variables SOCKET_CLI_COANA_FORCE_NPM_INSTALL
375+
* ('npm-install') and SOCKET_CLI_COANA_DISABLE_NPM_FALLBACK ('npx') are still
376+
* honored when the new variable is unset, but are intentionally undocumented.
377+
*/
378+
function getCoanaLauncherMode(): CoanaLauncherMode {
379+
const rawMode = process.env['SOCKET_CLI_COANA_LAUNCHER']
380+
const mode = rawMode?.trim().toLowerCase()
381+
if (mode) {
382+
if (mode === 'auto' || mode === 'npm-install' || mode === 'npx') {
383+
return mode
384+
}
385+
logger.warn(
386+
`Ignoring unrecognized SOCKET_CLI_COANA_LAUNCHER value "${rawMode}"; expected "auto", "npm-install", or "npx".`,
387+
)
388+
return 'auto'
389+
}
390+
if (process.env['SOCKET_CLI_COANA_FORCE_NPM_INSTALL']) {
391+
return 'npm-install'
392+
}
393+
if (process.env['SOCKET_CLI_COANA_DISABLE_NPM_FALLBACK']) {
394+
return 'npx'
395+
}
396+
return 'auto'
397+
}
398+
353399
/**
354400
* Helper to spawn coana with dlx.
355401
* Automatically uses force and silent when version is not pinned exactly.
@@ -360,9 +406,10 @@ async function spawnCoanaViaNpmInstall(
360406
*
361407
* If the dlx path fails (e.g. broken `npx` on the host), falls back to
362408
* `npm install`-ing @coana-tech/cli into a temp directory and invoking it
363-
* directly via `node`. The fallback can be disabled with
364-
* SOCKET_CLI_COANA_DISABLE_NPM_FALLBACK or forced as the primary path with
365-
* SOCKET_CLI_COANA_FORCE_NPM_INSTALL.
409+
* directly via `node`. The launcher strategy can be overridden with
410+
* SOCKET_CLI_COANA_LAUNCHER: 'auto' (the default) tries dlx with the
411+
* npm-install fallback, 'npm-install' skips dlx entirely, and 'npx' never
412+
* falls back.
366413
*/
367414
export async function spawnCoanaDlx(
368415
args: string[] | readonly string[],
@@ -416,6 +463,18 @@ export async function spawnCoanaDlx(
416463
const resolvedVersion =
417464
coanaVersion || constants.ENV.INLINED_SOCKET_CLI_COANA_TECH_CLI_VERSION
418465

466+
// `shadowNpmBase` (the dlx launcher) configures the child's stdio from its
467+
// `options` arg, NOT from the registry-spawn `extra` arg — the latter only
468+
// attaches metadata to the result. Callers that requested streaming via
469+
// `spawnExtra` (the 4th arg), e.g. `{ stdio: 'inherit' }` from
470+
// `socket manifest gradle`, were therefore silently ignored on this path:
471+
// Coana ran piped and its output — including the real failure reason — never
472+
// reached the user, leaving only an unhelpful "command failed". Resolve the
473+
// requested stdio from either argument and honor it on every launch path:
474+
// dlx, local-path, and npm-install (e.g. `socket fix --silence` requests
475+
// `stdio: 'pipe'` via options).
476+
const requestedStdio = spawnExtra?.['stdio'] ?? getOwn(dlxOptions, 'stdio')
477+
419478
const localCoanaPath = process.env['SOCKET_CLI_COANA_LOCAL_PATH']
420479
// Use local Coana CLI if path is provided.
421480
if (localCoanaPath) {
@@ -424,38 +483,28 @@ export async function spawnCoanaDlx(
424483
localCoanaPath,
425484
args,
426485
finalEnv,
427-
{ cwd: dlxOptions.cwd },
486+
{ cwd: dlxOptions.cwd, stdio: requestedStdio },
428487
spawnExtra,
429488
)
430489
} catch (e) {
431490
return buildDlxErrorResult(e)
432491
}
433492
}
434493

494+
const launcherMode = getCoanaLauncherMode()
495+
435496
// Allow forcing the npm-install path for debugging or for environments
436497
// where dlx is known-broken.
437-
if (process.env['SOCKET_CLI_COANA_FORCE_NPM_INSTALL']) {
498+
if (launcherMode === 'npm-install') {
438499
return await spawnCoanaViaNpmInstall(
439500
args,
440501
resolvedVersion,
441502
finalEnv,
442-
{ cwd: dlxOptions.cwd },
503+
{ cwd: dlxOptions.cwd, stdio: requestedStdio },
443504
spawnExtra,
444505
)
445506
}
446507

447-
// `shadowNpmBase` (the dlx launcher) configures the child's stdio from its
448-
// `options` arg, NOT from the registry-spawn `extra` arg — the latter only
449-
// attaches metadata to the result. Callers that requested streaming via
450-
// `spawnExtra` (the 4th arg), e.g. `{ stdio: 'inherit' }` from
451-
// `socket manifest gradle`, were therefore silently ignored on this path:
452-
// Coana ran piped and its output — including the real failure reason — never
453-
// reached the user, leaving only an unhelpful "command failed". Promote the
454-
// requested stdio into the dlx options so it is honored here too.
455-
// `spawnCoanaScriptViaNode` already reads `spawnExtra.stdio` for the
456-
// local-path and npm-install branches, so this aligns all three paths.
457-
const requestedStdio = spawnExtra?.['stdio'] ?? getOwn(dlxOptions, 'stdio')
458-
459508
try {
460509
// Use npm/dlx version.
461510
const result = await spawnDlx(
@@ -490,7 +539,7 @@ export async function spawnCoanaDlx(
490539
} catch (e) {
491540
const dlxError = buildDlxErrorResult(e)
492541

493-
if (process.env['SOCKET_CLI_COANA_DISABLE_NPM_FALLBACK']) {
542+
if (launcherMode === 'npx') {
494543
return dlxError
495544
}
496545

@@ -509,7 +558,7 @@ export async function spawnCoanaDlx(
509558
args,
510559
resolvedVersion,
511560
finalEnv,
512-
{ cwd: dlxOptions.cwd },
561+
{ cwd: dlxOptions.cwd, stdio: requestedStdio },
513562
spawnExtra,
514563
)
515564
if (fallbackResult.ok) {

src/utils/dlx.test.mts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,7 @@ describe('utils/dlx', () => {
224224
beforeEach(async () => {
225225
delete process.env['SOCKET_CLI_COANA_FORCE_NPM_INSTALL']
226226
delete process.env['SOCKET_CLI_COANA_DISABLE_NPM_FALLBACK']
227+
delete process.env['SOCKET_CLI_COANA_LAUNCHER']
227228
delete process.env['SOCKET_CLI_COANA_LOCAL_PATH']
228229

229230
installRoot = await fs.mkdtemp(
@@ -296,6 +297,7 @@ describe('utils/dlx', () => {
296297
mockSpawn.mockReset()
297298
delete process.env['SOCKET_CLI_COANA_FORCE_NPM_INSTALL']
298299
delete process.env['SOCKET_CLI_COANA_DISABLE_NPM_FALLBACK']
300+
delete process.env['SOCKET_CLI_COANA_LAUNCHER']
299301
await fs.rm(installRoot, { recursive: true, force: true })
300302
})
301303

@@ -383,6 +385,99 @@ describe('utils/dlx', () => {
383385
expect(npmInstallCalls).toHaveLength(1)
384386
})
385387

388+
it('skips fallback when SOCKET_CLI_COANA_LAUNCHER is npx', async () => {
389+
process.env['SOCKET_CLI_COANA_LAUNCHER'] = 'npx'
390+
391+
const result = await spawnCoanaDlx(['run', '.'], 'acme', {
392+
coanaVersion: nextVersion(),
393+
})
394+
395+
expect(result.ok).toBe(false)
396+
// No npm install was attempted.
397+
const npmInstallCalls = mockSpawn.mock.calls.filter(
398+
([cmd, args]) => cmd === 'npm' && (args as string[])[0] === 'install',
399+
)
400+
expect(npmInstallCalls).toHaveLength(0)
401+
})
402+
403+
it('skips dlx and goes straight to install when SOCKET_CLI_COANA_LAUNCHER is npm-install', async () => {
404+
process.env['SOCKET_CLI_COANA_LAUNCHER'] = 'npm-install'
405+
406+
const result = await spawnCoanaDlx(['run', '.'], 'acme', {
407+
coanaVersion: nextVersion(),
408+
})
409+
410+
expect(result.ok).toBe(true)
411+
// dlx (any shadow bin) was never invoked.
412+
expect(mockDlxBin).not.toHaveBeenCalled()
413+
// npm install ran.
414+
const npmInstallCalls = mockSpawn.mock.calls.filter(
415+
([cmd, args]) => cmd === 'npm' && (args as string[])[0] === 'install',
416+
)
417+
expect(npmInstallCalls).toHaveLength(1)
418+
})
419+
420+
it('prefers SOCKET_CLI_COANA_LAUNCHER over the legacy variables', async () => {
421+
process.env['SOCKET_CLI_COANA_LAUNCHER'] = 'auto'
422+
process.env['SOCKET_CLI_COANA_FORCE_NPM_INSTALL'] = '1'
423+
process.env['SOCKET_CLI_COANA_DISABLE_NPM_FALLBACK'] = '1'
424+
425+
const result = await spawnCoanaDlx(['run', '.'], 'acme', {
426+
coanaVersion: nextVersion(),
427+
})
428+
429+
// The legacy variables are ignored: dlx is still attempted (not forced
430+
// to npm install) and the fallback still runs (not disabled).
431+
expect(result.ok).toBe(true)
432+
expect(mockDlxBin).toHaveBeenCalledTimes(1)
433+
const npmInstallCalls = mockSpawn.mock.calls.filter(
434+
([cmd, args]) => cmd === 'npm' && (args as string[])[0] === 'install',
435+
)
436+
expect(npmInstallCalls).toHaveLength(1)
437+
})
438+
439+
it('treats an unrecognized SOCKET_CLI_COANA_LAUNCHER value as auto', async () => {
440+
process.env['SOCKET_CLI_COANA_LAUNCHER'] = 'bogus'
441+
442+
const result = await spawnCoanaDlx(['run', '.'], 'acme', {
443+
coanaVersion: nextVersion(),
444+
})
445+
446+
// Default behavior: dlx attempted, then the npm-install fallback.
447+
expect(result.ok).toBe(true)
448+
expect(mockDlxBin).toHaveBeenCalledTimes(1)
449+
const npmInstallCalls = mockSpawn.mock.calls.filter(
450+
([cmd, args]) => cmd === 'npm' && (args as string[])[0] === 'install',
451+
)
452+
expect(npmInstallCalls).toHaveLength(1)
453+
})
454+
455+
it('honors options.stdio on the npm-install path', async () => {
456+
process.env['SOCKET_CLI_COANA_LAUNCHER'] = 'npm-install'
457+
458+
const result = await spawnCoanaDlx(['run', '.'], 'acme', {
459+
coanaVersion: nextVersion(),
460+
stdio: 'pipe',
461+
})
462+
463+
expect(result.ok).toBe(true)
464+
const nodeCalls = mockSpawn.mock.calls.filter(([cmd]) => cmd === 'node')
465+
expect(nodeCalls).toHaveLength(1)
466+
expect((nodeCalls[0]![2] as { stdio?: unknown }).stdio).toBe('pipe')
467+
})
468+
469+
it('honors options.stdio in the auto-mode npm-install fallback', async () => {
470+
const result = await spawnCoanaDlx(['run', '.'], 'acme', {
471+
coanaVersion: nextVersion(),
472+
stdio: 'pipe',
473+
})
474+
475+
expect(result.ok).toBe(true)
476+
const nodeCalls = mockSpawn.mock.calls.filter(([cmd]) => cmd === 'node')
477+
expect(nodeCalls).toHaveLength(1)
478+
expect((nodeCalls[0]![2] as { stdio?: unknown }).stdio).toBe('pipe')
479+
})
480+
386481
it('surfaces both dlx and install errors when fallback install fails', async () => {
387482
// Make npm install fail; node would not be reached.
388483
mockSpawn.mockImplementation(async (cmd: string) => {
@@ -577,6 +672,7 @@ describe('utils/dlx', () => {
577672
beforeEach(() => {
578673
delete process.env['SOCKET_CLI_COANA_FORCE_NPM_INSTALL']
579674
delete process.env['SOCKET_CLI_COANA_DISABLE_NPM_FALLBACK']
675+
delete process.env['SOCKET_CLI_COANA_LAUNCHER']
580676
delete process.env['SOCKET_CLI_COANA_LOCAL_PATH']
581677

582678
// The dlx launcher succeeds by default. spawnDlx picks the shadow bin by

0 commit comments

Comments
 (0)