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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ 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/).

## [1.1.115](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.115) - 2026-06-04

### Fixed
- `socket manifest gradle`, `kotlin`, and `scala` (including sbt-based projects) now stream the underlying build-tool and Coana output and surface the real failure reason. Previously a generation failure could collapse to an unhelpful `Coana command failed (exit code 1): command failed` with no detail, hiding actionable hints such as unresolved dependencies (re-run with `--ignore-unresolved` / `--exclude-configs`, or `--pom` for the legacy `pom.xml` output).

## [1.1.114](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.114) - 2026-06-04

### Changed
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "socket",
"version": "1.1.114",
"version": "1.1.115",
"description": "CLI for Socket.dev",
"homepage": "https://github.com/SocketDev/socket-cli",
"license": "MIT AND OFL-1.1",
Expand Down
88 changes: 77 additions & 11 deletions src/utils/dlx.mts
Original file line number Diff line number Diff line change
Expand Up @@ -444,6 +444,18 @@ export async function spawnCoanaDlx(
)
}

// `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 All @@ -454,8 +466,14 @@ export async function spawnCoanaDlx(
args,
{
force: true,
silent: 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,
Expand Down Expand Up @@ -484,7 +502,7 @@ export async function spawnCoanaDlx(
}

logger.warn(
'Coana dlx invocation failed before Coana started; falling back to `npm install` + `node`.',
'Coana dlx invocation failed; retrying via `npm install` + `node`.',
)

const fallbackResult = await spawnCoanaViaNpmInstall(
Expand Down Expand Up @@ -526,10 +544,29 @@ export async function spawnCoanaDlx(
* rather than blindly re-running Coana.
*/
function shouldFallbackOnDlxError(e: unknown): boolean {
const capturedStderr = String((e as any)?.stderr ?? '')
if (capturedStderr && /Coana CLI version/i.test(capturedStderr)) {
// 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)
Comment thread
mtorp marked this conversation as resolved.
}

/**
* 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') {
Expand All @@ -541,10 +578,18 @@ function shouldFallbackOnDlxError(e: unknown): boolean {
}
// Exit codes >= 128 are conventionally signal-derived, and the observed
// npx-launcher failures in the wild fall into this range (e.g. 249, 254).
if (typeof code === 'number' && code >= 128) {
return true
}
return false
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)
}

/**
Expand All @@ -553,6 +598,7 @@ function shouldFallbackOnDlxError(e: unknown): boolean {
*/
function buildDlxErrorResult(e: unknown): CResult<string> {
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)
Expand All @@ -564,9 +610,29 @@ function buildDlxErrorResult(e: unknown): CResult<string> {
details.push(`signal ${signal}`)
}
const detailSuffix = details.length ? ` (${details.join(', ')})` : ''
const message = stderr
? `Coana command failed${detailSuffix}: ${stderr}`
: `Coana command failed${detailSuffix}: ${cause}`
// 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,
Expand Down
160 changes: 158 additions & 2 deletions src/utils/dlx.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,10 @@ describe('utils/dlx', () => {
})

expect(result.ok).toBe(false)
expect(result.message).toContain('Coana command failed')
// exit 249 is ambiguous, so the message stays neutral about launcher-vs-Coana.
expect(result.message).toContain(
'Coana failed to run via the package manager',
)
// No npm install was attempted.
const npmInstallCalls = mockSpawn.mock.calls.filter(
([cmd, args]) => cmd === 'npm' && (args as string[])[0] === 'install',
Expand Down Expand Up @@ -396,7 +399,9 @@ describe('utils/dlx', () => {
})

expect(result.ok).toBe(false)
expect(result.message).toContain('Coana command failed')
expect(result.message).toContain(
'Coana failed to run via the package manager',
)
expect(result.message).toContain('npx aborted')
expect(result.message).toContain('npm-install fallback also failed')
expect(result.message).toContain('registry unreachable')
Expand All @@ -413,13 +418,55 @@ describe('utils/dlx', () => {

expect(result.ok).toBe(false)
expect(result.message).toContain('exit code 1')
// A small-int exit is ambiguous (could be Coana, or a launcher/download
// failure exiting 1), so the message must not assert Coana itself failed.
expect(result.message).not.toContain('Coana command failed')
expect(result.message).toContain(
'Coana failed to run via the package manager',
)
// 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('reports a definitive launch failure for a spawn-level error (the launcher could not start)', async () => {
// ENOENT: the launcher binary (npx) is missing from PATH, so Coana
// provably never ran. Disable the fallback so the dlx error is surfaced.
process.env['SOCKET_CLI_COANA_DISABLE_NPM_FALLBACK'] = '1'
setDlxRejection({ code: 'ENOENT' })

const result = await spawnCoanaDlx(['manifest', 'gradle', '.'], 'acme', {
coanaVersion: nextVersion(),
})

expect(result.ok).toBe(false)
expect(result.message).toContain('Failed to launch Coana')
expect(result.message).toContain('could not start')
expect(result.message).not.toContain('Coana command failed')
})

it('does NOT claim a launch failure for an ambiguous signal/high exit code (Coana may have started)', async () => {
// exit 137 (OOM-style) is ambiguous: Coana may have started, streamed
// output, and been killed — or the launcher may have failed. The message
// must not assert either way. Disable the fallback so it is surfaced.
process.env['SOCKET_CLI_COANA_DISABLE_NPM_FALLBACK'] = '1'
setDlxRejection({ code: 137 })

const result = await spawnCoanaDlx(['manifest', 'gradle', '.'], 'acme', {
coanaVersion: nextVersion(),
})

expect(result.ok).toBe(false)
expect(result.message).toContain('exit code 137')
expect(result.message).toContain(
'Coana failed to run via the package manager',
)
expect(result.message).not.toContain('Failed to launch Coana')
expect(result.message).not.toContain('before Coana started')
})

it('does NOT fall back when captured stderr shows Coana booted', async () => {
// Coana banner present in stderr → Coana clearly ran, so any subsequent
// failure is a real Coana issue, not a launcher problem.
Expand Down Expand Up @@ -518,4 +565,113 @@ describe('utils/dlx', () => {
}
})
})

describe('spawnCoanaDlx stdio + error surfacing', () => {
let mockDlxBin: ReturnType<typeof vi.fn>
let testCounter = 0

// Exact-pinned versions so the dlx silent/force defaults stay deterministic
// and each test stays clear of the module-level install cache.
const nextVersion = () => `98.0.${testCounter++}`

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_LOCAL_PATH']

// The dlx launcher succeeds by default. spawnDlx picks the shadow bin by
// lockfile, so wire all three (npm/pnpm/yarn) to the same mock.
mockDlxBin = vi.fn().mockImplementation(async () => ({
spawnPromise: Promise.resolve({ stdout: 'coana-ok', stderr: '' }),
}))
for (const binPath of [
constants.shadowNpxBinPath,
constants.shadowPnpmBinPath,
constants.shadowYarnBinPath,
]) {
// @ts-ignore
require.cache[binPath] = { exports: mockDlxBin }
}
})

afterEach(() => {
for (const binPath of [
constants.shadowNpxBinPath,
constants.shadowPnpmBinPath,
constants.shadowYarnBinPath,
]) {
// @ts-ignore
delete require.cache[binPath]
}
})

it('forwards spawnExtra.stdio into the dlx launcher options (regression)', async () => {
// `socket manifest gradle` passes `{ stdio: 'inherit' }` as spawnExtra so
// Coana's gradle output streams to the user. Before the fix this was
// dropped — the launcher reads stdio from its options, not the registry
// spawn `extra` arg — so Coana ran piped and the real failure reason was
// hidden behind a bare "command failed".
const result = await spawnCoanaDlx(
['manifest', 'gradle', '.'],
'acme',
{ coanaVersion: nextVersion() },
{ stdio: 'inherit' },
)

expect(result.ok).toBe(true)
expect(mockDlxBin).toHaveBeenCalledTimes(1)
const launcherOptions = mockDlxBin.mock.calls[0]![1] as {
stdio?: unknown
}
expect(launcherOptions.stdio).toBe('inherit')
})

it('does not pass --silent to the launcher (so npm download/launch errors surface)', async () => {
// `--silent` (npm loglevel silent) would hide the very download/registry
// errors that explain why npx failed to launch Coana.
const result = await spawnCoanaDlx(['manifest', 'gradle', '.'], 'acme', {
coanaVersion: nextVersion(),
})

expect(result.ok).toBe(true)
const launcherArgs = mockDlxBin.mock.calls[0]![0] as string[]
expect(launcherArgs).not.toContain('--silent')
})

it('forwards options.stdio into the dlx launcher options', async () => {
const result = await spawnCoanaDlx(['run', '.'], 'acme', {
coanaVersion: nextVersion(),
stdio: 'inherit',
})

expect(result.ok).toBe(true)
const launcherOptions = mockDlxBin.mock.calls[0]![1] as {
stdio?: unknown
}
expect(launcherOptions.stdio).toBe('inherit')
})

it('surfaces captured stdout when stderr is empty (Coana logs some failures to stdout)', async () => {
mockDlxBin.mockReset()
mockDlxBin.mockImplementation(async () => {
const rejected = Promise.reject(
Object.assign(new Error('command failed'), {
code: 1,
stdout: 'error: Could not resolve 1 dependency(ies)',
stderr: '',
}),
)
rejected.catch(() => {})
return { spawnPromise: rejected }
})

const result = await spawnCoanaDlx(['manifest', 'gradle', '.'], 'acme', {
coanaVersion: nextVersion(),
})

expect(result.ok).toBe(false)
expect(result.message).toContain('exit code 1')
expect(result.message).toContain('Could not resolve 1 dependency(ies)')
})
})
})
Loading