Skip to content
Open
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
51 changes: 14 additions & 37 deletions packages/cli/src/cli-entry.mts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@ process.emitWarning = function (warning, ...args) {
return Reflect.apply(originalEmitWarning, this, [warning, ...args])
}

import { messageWithCauses, stackWithCauses } from 'pony-cause'
import lookupRegistryAuthToken from 'registry-auth-token'
import lookupRegistryUrl from 'registry-url'

Expand All @@ -43,6 +42,7 @@ import {
getSocketCliBootstrapSpec,
} from '@socketsecurity/lib/env/socket-cli'
import { getDefaultLogger } from '@socketsecurity/lib/logger'
import { getDefaultSpinner } from '@socketsecurity/lib/spinner'

import { rootAliases, rootCommands } from './commands.mts'
import { SOCKET_CLI_BIN_NAME } from './constants/packages.mts'
Expand All @@ -53,11 +53,10 @@ import { VITEST } from './env/vitest.mts'
import meow from './meow.mts'
import { meowWithSubcommands } from './utils/cli/with-subcommands.mts'
import {
AuthError,
captureException,
InputError,
} from './utils/error/errors.mts'
import { failMsgWithBadge } from './utils/error/fail-msg-with-badge.mts'
formatErrorForJson,
formatErrorForTerminal,
} from './utils/error/display.mts'
import { captureException } from './utils/error/errors.mts'
import { serializeResultJson } from './utils/output/result-json.mts'
import { runPreflightDownloads } from './utils/preflight/downloads.mts'
import { isSeaBinary } from './utils/sea/detect.mts'
Expand Down Expand Up @@ -176,29 +175,17 @@ void (async () => {
} catch (e) {
process.exitCode = 1

// Stop any active spinner before emitting error output, otherwise
// its animation clashes with the error text on the same line.
// Spinner-wrapped command paths stop their own on catch, but any
// exception that bypasses those handlers reaches us here.
getDefaultSpinner()?.stop()

// Track CLI error for telemetry.
await trackCliError(process.argv, cliStartTime, e, process.exitCode)
debug('CLI uncaught error')
debugDir(e)

let errorBody: string | undefined
let errorTitle: string
let errorMessage = ''
if (e instanceof AuthError) {
errorTitle = 'Authentication error'
errorMessage = e.message
} else if (e instanceof InputError) {
errorTitle = 'Invalid input'
errorMessage = e.message
errorBody = e.body
} else if (e instanceof Error) {
errorTitle = 'Unexpected error'
errorMessage = messageWithCauses(e)
errorBody = stackWithCauses(e)
} else {
errorTitle = 'Unexpected error with no details'
}

// Try to parse the flags, find out if --json is set.
const isJson = (() => {
const cli = meow({
Expand All @@ -213,20 +200,10 @@ void (async () => {
})()

if (isJson) {
logger.log(
serializeResultJson({
ok: false,
message: errorTitle,
cause: errorMessage,
}),
)
logger.log(serializeResultJson(formatErrorForJson(e)))
} else {
// Add 2 newlines in stderr to bump below any spinner.
logger.error('\n')
logger.fail(failMsgWithBadge(errorTitle, errorMessage))
if (errorBody) {
debugDirNs('inspect', { errorBody })
}
logger.error(formatErrorForTerminal(e))
Comment thread
jdalton marked this conversation as resolved.
Comment thread
jdalton marked this conversation as resolved.
debugDirNs('inspect', { error: e })
}

await captureException(e)
Expand Down
16 changes: 16 additions & 0 deletions packages/cli/src/utils/error/display.mts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,23 @@ export function formatErrorForDisplay(
body = error.body
} else if (error instanceof Error) {
title = opts.title || 'Unexpected error'
// Concatenate the cause chain into `message` (what non-debug users see)
// so diagnostic context from wrapped errors isn't silently dropped.
// `showStack` adds a richer formatted body with stack traces below.
message = error.message
const plainCauses: string[] = []
let walk: unknown = error.cause
let walkDepth = 1
while (walk && walkDepth <= 5) {
plainCauses.push(
walk instanceof Error ? walk.message : String(walk),
)
walk = walk instanceof Error ? walk.cause : undefined
walkDepth++
}
if (plainCauses.length) {
message = `${message}: ${plainCauses.join(': ')}`
}

if (showStack && error.stack) {
// Format stack trace with proper indentation.
Expand Down
6 changes: 5 additions & 1 deletion packages/cli/src/utils/socket/api.mts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
} from '../ecosystem/requirements.mts'
import {
buildErrorCause,
ConfigError,
getNetworkErrorDiagnostics,
} from '../error/errors.mts'

Expand Down Expand Up @@ -383,7 +384,10 @@ export async function handleApiCallNoSpinner<T extends SocketSdkOperations>(
export async function queryApi(path: string, apiToken: string) {
const baseUrl = getDefaultApiBaseUrl()
if (!baseUrl) {
throw new Error('Socket API base URL is not configured.')
throw new ConfigError(
'Socket API base URL is not configured.',
CONFIG_KEY_API_BASE_URL,
)
}

return await socketHttpRequest(
Expand Down
32 changes: 32 additions & 0 deletions packages/cli/test/unit/utils/error/display.test.mts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,38 @@ describe('error/display', () => {
expect(result.message).toBe('Something went wrong')
})

it('preserves Error.cause chain in message without debug mode', () => {
// Regression: formatErrorForDisplay used to only surface causes
// under showStack/verbose, so non-debug users lost the most useful
// diagnostic context. See PR #1238.
const inner = new Error('root DNS failure')
const middle = new Error('network call failed', { cause: inner })
const outer = new Error('API request failed', { cause: middle })

const result = formatErrorForDisplay(outer)

expect(result.message).toContain('API request failed')
expect(result.message).toContain('network call failed')
expect(result.message).toContain('root DNS failure')
})

it('stops walking causes at depth 5 to avoid runaway chains', () => {
// Build inside-out so the outer Error sits at index 10 and chains
// down through level-9, level-8, ..., level-0.
let e: Error | undefined
for (let i = 0; i <= 10; i++) {
e = new Error(`level-${i}`, e ? { cause: e } : undefined)
}

const result = formatErrorForDisplay(e!)

// Top message + 5 causes should appear.
expect(result.message).toContain('level-10')
expect(result.message).toContain('level-5')
// Anything beyond depth 5 should have been truncated.
expect(result.message).not.toContain('level-4')
})

it('uses custom title when provided', () => {
const error = new Error('Something went wrong')

Expand Down