Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Next Next commit
fix(manifest): stream Coana output and surface the real failure reason
`socket manifest {gradle,kotlin,scala}` delegate Socket facts generation to
the Coana CLI via spawnCoanaDlx, passing `{ stdio: 'inherit' }` so the
build-tool and Coana output streams to the user. On the dlx path that stdio
was silently dropped: shadowNpmBase configures the child's stdio from its
`options` arg, not the registry-spawn `extra` arg, so Coana ran piped and its
output — including the actual failure reason — never reached the user. A
generation failure then collapsed to an unhelpful
"Coana command failed (exit code 1): command failed" with no detail.

- spawnCoanaDlx now promotes the requested stdio (from spawnExtra, falling
  back to options) into the dlx launcher options, aligning the dlx path with
  the local-path and npm-install branches that already honor spawnExtra.stdio.
- buildDlxErrorResult falls back to captured stdout when stderr is empty,
  since Coana logs some failures (e.g. unresolved dependencies) to stdout.

Add regression tests and bump the CLI to 1.1.115.
  • Loading branch information
mtorp committed Jun 5, 2026
commit 0a6162ef83620a62f60947cd8f0f920a9417d80f
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
23 changes: 21 additions & 2 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 @@ -456,6 +468,7 @@ export async function spawnCoanaDlx(
force: true,
silent: true,
...dlxOptions,
...(requestedStdio === undefined ? {} : { stdio: requestedStdio }),
env: finalEnv,
ipc: {
[constants.SOCKET_CLI_SHADOW_ACCEPT_RISKS]: true,
Expand Down Expand Up @@ -553,6 +566,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,8 +578,13 @@ function buildDlxErrorResult(e: unknown): CResult<string> {
details.push(`signal ${signal}`)
}
const detailSuffix = details.length ? ` (${details.join(', ')})` : ''
const message = stderr
? `Coana command failed${detailSuffix}: ${stderr}`
// 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 captured = stderr || stdout
const message = captured
? `Coana command failed${detailSuffix}: ${captured}`
: `Coana command failed${detailSuffix}: ${cause}`
return {
ok: false,
Expand Down
97 changes: 97 additions & 0 deletions src/utils/dlx.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -518,4 +518,101 @@ 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('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