diff --git a/CHANGELOG.md b/CHANGELOG.md index c582ee89b..7f8a7cd89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/package.json b/package.json index 53d27b07e..ebf8fc3ce 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/utils/dlx.mts b/src/utils/dlx.mts index 4328a0e8c..510836c52 100644 --- a/src/utils/dlx.mts +++ b/src/utils/dlx.mts @@ -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( @@ -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, @@ -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( @@ -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) +} + +/** + * 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') { @@ -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) } /** @@ -553,6 +598,7 @@ function shouldFallbackOnDlxError(e: unknown): boolean { */ function buildDlxErrorResult(e: unknown): CResult { 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) @@ -564,9 +610,29 @@ function buildDlxErrorResult(e: unknown): CResult { 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, diff --git a/src/utils/dlx.test.mts b/src/utils/dlx.test.mts index ce4e59a91..94153267c 100644 --- a/src/utils/dlx.test.mts +++ b/src/utils/dlx.test.mts @@ -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', @@ -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') @@ -413,6 +418,12 @@ 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', @@ -420,6 +431,42 @@ describe('utils/dlx', () => { 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. @@ -518,4 +565,113 @@ describe('utils/dlx', () => { } }) }) + + describe('spawnCoanaDlx stdio + error surfacing', () => { + let mockDlxBin: ReturnType + 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)') + }) + }) })