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
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,6 @@
"oxfmt": "0.37.0",
"oxlint": "1.52.0",
"package-builder": "workspace:*",
"pony-cause": "catalog:",
"postject": "catalog:",
"registry-auth-token": "catalog:",
"registry-url": "catalog:",
Expand Down
1 change: 0 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,6 @@
"npm-package-arg": "catalog:",
"open": "catalog:",
"package-builder": "workspace:*",
"pony-cause": "catalog:",
"registry-auth-token": "catalog:",
"registry-url": "catalog:",
"semver": "catalog:",
Expand Down
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
26 changes: 23 additions & 3 deletions packages/cli/src/utils/error/display.mts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import colors from 'yoctocolors-cjs'

import { messageWithCauses } from '@socketsecurity/lib/errors'
import { LOG_SYMBOLS } from '@socketsecurity/lib/logger'
import { stripAnsi } from '@socketsecurity/lib/strings'

Expand All @@ -25,6 +26,21 @@ export type ErrorDisplayOptions = {
verbose?: boolean | undefined
}

/**
* Append the `.cause` chain to a decorated base message. Typed errors
* build their message with suffixes (e.g. ` (HTTP 500)`) before this
* is called, so we can't just `messageWithCauses(error)` — we decorate
* first, then delegate cause walking to socket-lib.
*/
function appendCauseChain(baseMessage: string, cause: unknown): string {
if (!cause) {
return baseMessage
}
const causeText =
cause instanceof Error ? messageWithCauses(cause) : String(cause)
return `${baseMessage}: ${causeText}`
}

/**
* Format an error for display with polish and clarity.
* Uses LOG_SYMBOLS and colors for visual hierarchy.
Expand All @@ -47,34 +63,38 @@ export function formatErrorForDisplay(
if (error.retryAfter) {
message += ` (retry after ${error.retryAfter}s)`
}
message = appendCauseChain(message, error.cause)
} else if (error instanceof AuthError) {
title = 'Authentication error'
message = error.message
message = appendCauseChain(error.message, error.cause)
} else if (error instanceof NetworkError) {
title = 'Network error'
message = error.message
if (error.statusCode) {
message += ` (HTTP ${error.statusCode})`
}
message = appendCauseChain(message, error.cause)
} else if (error instanceof FileSystemError) {
title = 'File system error'
message = error.message
if (error.path) {
message += ` (${error.path})`
}
message = appendCauseChain(message, error.cause)
} else if (error instanceof ConfigError) {
title = 'Configuration error'
message = error.message
if (error.configKey) {
message += ` (key: ${error.configKey})`
}
message = appendCauseChain(message, error.cause)
} else if (error instanceof InputError) {
title = 'Invalid input'
message = error.message
message = appendCauseChain(error.message, error.cause)
body = error.body
} else if (error instanceof Error) {
title = opts.title || 'Unexpected error'
message = error.message
message = appendCauseChain(error.message, error.cause)

if (showStack && error.stack) {
// Format stack trace with proper indentation.
Expand Down
9 changes: 6 additions & 3 deletions packages/cli/src/utils/socket/api.mts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,9 @@
* - Falls back to configured apiBaseUrl or default API_V0_URL
*/

import { messageWithCauses } from 'pony-cause'

import { debug, debugDir } from '@socketsecurity/lib/debug'
import { getSocketCliApiBaseUrl } from '@socketsecurity/lib/env/socket-cli'
import { messageWithCauses } from '@socketsecurity/lib/errors'
import { httpRequest } from '@socketsecurity/lib/http-request'
import { getDefaultLogger } from '@socketsecurity/lib/logger'
import { getDefaultSpinner } from '@socketsecurity/lib/spinner'
Expand Down Expand Up @@ -55,6 +54,7 @@ import {
} from '../ecosystem/requirements.mts'
import {
buildErrorCause,
ConfigError,
getNetworkErrorDiagnostics,
} from '../error/errors.mts'

Expand Down Expand Up @@ -383,7 +383,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
25 changes: 25 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,31 @@ describe('error/display', () => {
expect(result.message).toBe('Something went wrong')
})

it('preserves Error.cause chain in message without debug mode', () => {
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('terminates on cyclic cause chains', () => {
const a = new Error('a')
const b = new Error('b')
;(a as Error & { cause?: unknown }).cause = b
;(b as Error & { cause?: unknown }).cause = a

const result = formatErrorForDisplay(a)

expect(result.message).toContain('a')
expect(result.message).toContain('b')
expect(result.message).toContain('...')
})

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

Expand Down
9 changes: 0 additions & 9 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,6 @@ catalog:
oxlint: 1.52.0
packageurl-js: npm:@socketregistry/packageurl-js@^1.4.2
path-parse: npm:@socketregistry/path-parse@^1.0.8
pony-cause: 2.1.11
postject: 1.0.0-alpha.6
react: 19.2.0
react-reconciler: 0.33.0
Expand Down