Skip to content

Commit 7d481f7

Browse files
authored
fix(manifest): stream Coana output and surface the real failure reason (#1353)
* 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. * fix(dlx): report launcher-vs-Coana failures honestly; stop muzzling the launcher 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. A failure there may be the launcher dying before Coana ever started (e.g. the package failed to download), yet buildDlxErrorResult always claimed "Coana command failed", and `silent: true` (npm loglevel silent) hid npm's own download/registry errors — so the user got a bare exit code with no cause and no hint that Coana itself may never have run. - buildDlxErrorResult now distinguishes three cases: Coana booted (banner seen) -> "Coana command failed"; launcher died before Coana started (spawn error / signal / exit >= 128) -> "Failed to launch Coana via the package manager ..."; ambiguous small-int exit -> neutral "Coana failed to run via the package manager" (no false blame on Coana). - Drop the forced `--silent` on the Coana launcher; shadowNpmBase still defaults to `--loglevel error`, so real launcher/download errors surface while success stays quiet. - Factor the launcher-vs-Coana heuristic into dlxLauncherFailedBeforeCoana and coanaBannerSeen, shared by shouldFallbackOnDlxError and the message builder. Known gap: the npm-install + node fallback (#1327) still fires only for spawn errors / signals / exit >= 128, not for a small-int exit. A broken npx that exits 1 (common for download failures) is therefore not yet auto-recovered. Add regression tests for the new wording and the un-muzzled launcher. * fix(dlx): don't mislabel ambiguous Coana failures as launcher failures Bugbot flagged that with stdio:'inherit' the spawn rejection carries no captured output, so coanaBannerSeen is always false and a Coana process that started then died by signal / exit >= 128 (e.g. OOM, exit 137) was wrongly reported as "the launcher exited before Coana started". Empirically, `coana manifest gradle` writes all output to stdout and never prints the "Coana CLI version" banner, so banner-based detection never worked for the manifest / reachability (inherit) paths anyway. Only a spawn-level error (a string `code` like ENOENT) definitively proves the launcher never started. - buildDlxErrorResult now claims a launch failure ONLY for a spawn-level error; signals and non-zero exits get neutral wording ("Coana failed to run via the package manager (exit code N)") since we cannot tell launcher-vs-Coana apart without captured output. - Soften the fallback warning to drop the "before Coana started" claim (it fires on the ambiguous >= 128 / signal cases too). The npm-install + node fallback gating is unchanged (pre-existing #1327 behavior): it still retries on spawn errors / signals / exit >= 128. Fully suppressing a retry when Coana actually ran would require capturing the launcher output, which conflicts with live streaming (and the reachability spinner); left as a possible follow-up. Update tests for the definitive (ENOENT) vs ambiguous (signal / >= 128) split.
1 parent e75b2d6 commit 7d481f7

4 files changed

Lines changed: 241 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@ All notable changes to this project will be documented in this file.
44

55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
66

7+
## [1.1.115](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.115) - 2026-06-04
8+
9+
### Fixed
10+
- `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).
11+
712
## [1.1.114](https://github.com/SocketDev/socket-cli/releases/tag/v1.1.114) - 2026-06-04
813

914
### Changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "socket",
3-
"version": "1.1.114",
3+
"version": "1.1.115",
44
"description": "CLI for Socket.dev",
55
"homepage": "https://github.com/SocketDev/socket-cli",
66
"license": "MIT AND OFL-1.1",

src/utils/dlx.mts

Lines changed: 77 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,18 @@ export async function spawnCoanaDlx(
444444
)
445445
}
446446

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+
447459
try {
448460
// Use npm/dlx version.
449461
const result = await spawnDlx(
@@ -454,8 +466,14 @@ export async function spawnCoanaDlx(
454466
args,
455467
{
456468
force: true,
457-
silent: true,
469+
// Do NOT silence the launcher. `--silent` (npm loglevel silent) hides
470+
// npm's own download/registry/launch errors, so when npx/pnpm-dlx fails
471+
// to fetch @coana-tech/cli the user is left with a bare exit code and no
472+
// cause. shadowNpmBase defaults to `--loglevel error`, which keeps real
473+
// launcher errors visible while staying quiet on success.
474+
silent: false,
458475
...dlxOptions,
476+
...(requestedStdio === undefined ? {} : { stdio: requestedStdio }),
459477
env: finalEnv,
460478
ipc: {
461479
[constants.SOCKET_CLI_SHADOW_ACCEPT_RISKS]: true,
@@ -484,7 +502,7 @@ export async function spawnCoanaDlx(
484502
}
485503

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

490508
const fallbackResult = await spawnCoanaViaNpmInstall(
@@ -526,10 +544,29 @@ export async function spawnCoanaDlx(
526544
* rather than blindly re-running Coana.
527545
*/
528546
function shouldFallbackOnDlxError(e: unknown): boolean {
529-
const capturedStderr = String((e as any)?.stderr ?? '')
530-
if (capturedStderr && /Coana CLI version/i.test(capturedStderr)) {
547+
// Coana clearly ran (its banner is in the captured stderr) → any later
548+
// non-zero exit is a real Coana failure and retrying would hit it again.
549+
if (coanaBannerSeen(e)) {
531550
return false
532551
}
552+
return dlxLauncherFailedBeforeCoana(e)
553+
}
554+
555+
/**
556+
* Heuristic: did the dlx launcher (npx / pnpm dlx / yarn dlx) fail BEFORE the
557+
* Coana process itself started? True for spawn-level errors (a string `code`
558+
* like ENOENT), signal kills, and exit codes >= 128 (conventionally
559+
* signal-derived) — all cases where the launcher, not Coana, is the culprit
560+
* (e.g. npx missing from PATH, or @coana-tech/cli failing to download). A small
561+
* integer exit code is deliberately NOT treated as a launch failure: Coana's
562+
* own exit codes are small integers too, so it is genuinely ambiguous.
563+
*
564+
* Caveat: a launcher that fails to download the package can also exit with a
565+
* small integer (npm/npx often exit 1), which lands in the ambiguous bucket.
566+
* We cannot disambiguate those from a real Coana exit without inspecting the
567+
* launcher's output, so the npm-install fallback does not fire for them.
568+
*/
569+
function dlxLauncherFailedBeforeCoana(e: unknown): boolean {
533570
const code = (e as any)?.code
534571
// Spawn-level failure (e.g. ENOENT when npx is missing from PATH).
535572
if (typeof code === 'string') {
@@ -541,10 +578,18 @@ function shouldFallbackOnDlxError(e: unknown): boolean {
541578
}
542579
// Exit codes >= 128 are conventionally signal-derived, and the observed
543580
// npx-launcher failures in the wild fall into this range (e.g. 249, 254).
544-
if (typeof code === 'number' && code >= 128) {
545-
return true
546-
}
547-
return false
581+
return typeof code === 'number' && code >= 128
582+
}
583+
584+
/**
585+
* Definitive proof Coana actually booted: its startup banner appears in the
586+
* captured stderr. Only available when the launcher's output was piped
587+
* (captured); with inherited stdio there is nothing to inspect, so this
588+
* returns false (the failure is then classified by exit code / signal alone).
589+
*/
590+
function coanaBannerSeen(e: unknown): boolean {
591+
const capturedStderr = String((e as any)?.stderr ?? '')
592+
return !!capturedStderr && /Coana CLI version/i.test(capturedStderr)
548593
}
549594

550595
/**
@@ -553,6 +598,7 @@ function shouldFallbackOnDlxError(e: unknown): boolean {
553598
*/
554599
function buildDlxErrorResult(e: unknown): CResult<string> {
555600
const stderr = (e as any)?.stderr
601+
const stdout = (e as any)?.stdout
556602
const exitCode = (e as any)?.code
557603
const signal = (e as any)?.signal
558604
const cause = getErrorCause(e)
@@ -564,9 +610,29 @@ function buildDlxErrorResult(e: unknown): CResult<string> {
564610
details.push(`signal ${signal}`)
565611
}
566612
const detailSuffix = details.length ? ` (${details.join(', ')})` : ''
567-
const message = stderr
568-
? `Coana command failed${detailSuffix}: ${stderr}`
569-
: `Coana command failed${detailSuffix}: ${cause}`
613+
// Prefer captured stderr, then stdout, then the generic spawn error. Coana
614+
// logs some failures (e.g. unresolved Gradle dependencies) to stdout, so
615+
// without the stdout fallback a piped failure collapsed to an unhelpful
616+
// "command failed" even when the real reason was captured.
617+
const detail = stderr || stdout || cause
618+
// Be honest about WHERE the failure happened. On the dlx path the spawned
619+
// process is the package-manager launcher (npx / pnpm dlx / yarn dlx), which
620+
// downloads @coana-tech/cli and only then runs it — so a failure may be the
621+
// launcher dying before Coana ever started, not Coana itself. We can only be
622+
// CERTAIN of that for a spawn-level error (a string `code` like ENOENT: the
623+
// launcher binary could not start, so Coana provably never ran). A non-zero
624+
// exit or signal is genuinely ambiguous — Coana may have started, streamed
625+
// output, and then died (e.g. OOM), or the launcher may have failed to fetch
626+
// the package — and with inherited stdio there is no captured output to tell
627+
// them apart, so we must not assert either way.
628+
let message: string
629+
if (coanaBannerSeen(e)) {
630+
message = `Coana command failed${detailSuffix}: ${detail}`
631+
} else if (typeof (e as any)?.code === 'string') {
632+
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}`
633+
} else {
634+
message = `Coana failed to run via the package manager${detailSuffix}: ${detail}`
635+
}
570636
return {
571637
ok: false,
572638
data: e,

src/utils/dlx.test.mts

Lines changed: 158 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -355,7 +355,10 @@ describe('utils/dlx', () => {
355355
})
356356

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

398401
expect(result.ok).toBe(false)
399-
expect(result.message).toContain('Coana command failed')
402+
expect(result.message).toContain(
403+
'Coana failed to run via the package manager',
404+
)
400405
expect(result.message).toContain('npx aborted')
401406
expect(result.message).toContain('npm-install fallback also failed')
402407
expect(result.message).toContain('registry unreachable')
@@ -413,13 +418,55 @@ describe('utils/dlx', () => {
413418

414419
expect(result.ok).toBe(false)
415420
expect(result.message).toContain('exit code 1')
421+
// A small-int exit is ambiguous (could be Coana, or a launcher/download
422+
// failure exiting 1), so the message must not assert Coana itself failed.
423+
expect(result.message).not.toContain('Coana command failed')
424+
expect(result.message).toContain(
425+
'Coana failed to run via the package manager',
426+
)
416427
// No npm install was attempted.
417428
const npmInstallCalls = mockSpawn.mock.calls.filter(
418429
([cmd, args]) => cmd === 'npm' && (args as string[])[0] === 'install',
419430
)
420431
expect(npmInstallCalls).toHaveLength(0)
421432
})
422433

434+
it('reports a definitive launch failure for a spawn-level error (the launcher could not start)', async () => {
435+
// ENOENT: the launcher binary (npx) is missing from PATH, so Coana
436+
// provably never ran. Disable the fallback so the dlx error is surfaced.
437+
process.env['SOCKET_CLI_COANA_DISABLE_NPM_FALLBACK'] = '1'
438+
setDlxRejection({ code: 'ENOENT' })
439+
440+
const result = await spawnCoanaDlx(['manifest', 'gradle', '.'], 'acme', {
441+
coanaVersion: nextVersion(),
442+
})
443+
444+
expect(result.ok).toBe(false)
445+
expect(result.message).toContain('Failed to launch Coana')
446+
expect(result.message).toContain('could not start')
447+
expect(result.message).not.toContain('Coana command failed')
448+
})
449+
450+
it('does NOT claim a launch failure for an ambiguous signal/high exit code (Coana may have started)', async () => {
451+
// exit 137 (OOM-style) is ambiguous: Coana may have started, streamed
452+
// output, and been killed — or the launcher may have failed. The message
453+
// must not assert either way. Disable the fallback so it is surfaced.
454+
process.env['SOCKET_CLI_COANA_DISABLE_NPM_FALLBACK'] = '1'
455+
setDlxRejection({ code: 137 })
456+
457+
const result = await spawnCoanaDlx(['manifest', 'gradle', '.'], 'acme', {
458+
coanaVersion: nextVersion(),
459+
})
460+
461+
expect(result.ok).toBe(false)
462+
expect(result.message).toContain('exit code 137')
463+
expect(result.message).toContain(
464+
'Coana failed to run via the package manager',
465+
)
466+
expect(result.message).not.toContain('Failed to launch Coana')
467+
expect(result.message).not.toContain('before Coana started')
468+
})
469+
423470
it('does NOT fall back when captured stderr shows Coana booted', async () => {
424471
// Coana banner present in stderr → Coana clearly ran, so any subsequent
425472
// failure is a real Coana issue, not a launcher problem.
@@ -518,4 +565,113 @@ describe('utils/dlx', () => {
518565
}
519566
})
520567
})
568+
569+
describe('spawnCoanaDlx stdio + error surfacing', () => {
570+
let mockDlxBin: ReturnType<typeof vi.fn>
571+
let testCounter = 0
572+
573+
// Exact-pinned versions so the dlx silent/force defaults stay deterministic
574+
// and each test stays clear of the module-level install cache.
575+
const nextVersion = () => `98.0.${testCounter++}`
576+
577+
beforeEach(() => {
578+
delete process.env['SOCKET_CLI_COANA_FORCE_NPM_INSTALL']
579+
delete process.env['SOCKET_CLI_COANA_DISABLE_NPM_FALLBACK']
580+
delete process.env['SOCKET_CLI_COANA_LOCAL_PATH']
581+
582+
// The dlx launcher succeeds by default. spawnDlx picks the shadow bin by
583+
// lockfile, so wire all three (npm/pnpm/yarn) to the same mock.
584+
mockDlxBin = vi.fn().mockImplementation(async () => ({
585+
spawnPromise: Promise.resolve({ stdout: 'coana-ok', stderr: '' }),
586+
}))
587+
for (const binPath of [
588+
constants.shadowNpxBinPath,
589+
constants.shadowPnpmBinPath,
590+
constants.shadowYarnBinPath,
591+
]) {
592+
// @ts-ignore
593+
require.cache[binPath] = { exports: mockDlxBin }
594+
}
595+
})
596+
597+
afterEach(() => {
598+
for (const binPath of [
599+
constants.shadowNpxBinPath,
600+
constants.shadowPnpmBinPath,
601+
constants.shadowYarnBinPath,
602+
]) {
603+
// @ts-ignore
604+
delete require.cache[binPath]
605+
}
606+
})
607+
608+
it('forwards spawnExtra.stdio into the dlx launcher options (regression)', async () => {
609+
// `socket manifest gradle` passes `{ stdio: 'inherit' }` as spawnExtra so
610+
// Coana's gradle output streams to the user. Before the fix this was
611+
// dropped — the launcher reads stdio from its options, not the registry
612+
// spawn `extra` arg — so Coana ran piped and the real failure reason was
613+
// hidden behind a bare "command failed".
614+
const result = await spawnCoanaDlx(
615+
['manifest', 'gradle', '.'],
616+
'acme',
617+
{ coanaVersion: nextVersion() },
618+
{ stdio: 'inherit' },
619+
)
620+
621+
expect(result.ok).toBe(true)
622+
expect(mockDlxBin).toHaveBeenCalledTimes(1)
623+
const launcherOptions = mockDlxBin.mock.calls[0]![1] as {
624+
stdio?: unknown
625+
}
626+
expect(launcherOptions.stdio).toBe('inherit')
627+
})
628+
629+
it('does not pass --silent to the launcher (so npm download/launch errors surface)', async () => {
630+
// `--silent` (npm loglevel silent) would hide the very download/registry
631+
// errors that explain why npx failed to launch Coana.
632+
const result = await spawnCoanaDlx(['manifest', 'gradle', '.'], 'acme', {
633+
coanaVersion: nextVersion(),
634+
})
635+
636+
expect(result.ok).toBe(true)
637+
const launcherArgs = mockDlxBin.mock.calls[0]![0] as string[]
638+
expect(launcherArgs).not.toContain('--silent')
639+
})
640+
641+
it('forwards options.stdio into the dlx launcher options', async () => {
642+
const result = await spawnCoanaDlx(['run', '.'], 'acme', {
643+
coanaVersion: nextVersion(),
644+
stdio: 'inherit',
645+
})
646+
647+
expect(result.ok).toBe(true)
648+
const launcherOptions = mockDlxBin.mock.calls[0]![1] as {
649+
stdio?: unknown
650+
}
651+
expect(launcherOptions.stdio).toBe('inherit')
652+
})
653+
654+
it('surfaces captured stdout when stderr is empty (Coana logs some failures to stdout)', async () => {
655+
mockDlxBin.mockReset()
656+
mockDlxBin.mockImplementation(async () => {
657+
const rejected = Promise.reject(
658+
Object.assign(new Error('command failed'), {
659+
code: 1,
660+
stdout: 'error: Could not resolve 1 dependency(ies)',
661+
stderr: '',
662+
}),
663+
)
664+
rejected.catch(() => {})
665+
return { spawnPromise: rejected }
666+
})
667+
668+
const result = await spawnCoanaDlx(['manifest', 'gradle', '.'], 'acme', {
669+
coanaVersion: nextVersion(),
670+
})
671+
672+
expect(result.ok).toBe(false)
673+
expect(result.message).toContain('exit code 1')
674+
expect(result.message).toContain('Could not resolve 1 dependency(ies)')
675+
})
676+
})
521677
})

0 commit comments

Comments
 (0)