diff --git a/package.json b/package.json index 92f2fe2ec..f01e121bf 100644 --- a/package.json +++ b/package.json @@ -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:", diff --git a/packages/cli/package.json b/packages/cli/package.json index ac16ab786..4b3b30098 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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:", diff --git a/packages/cli/src/cli-entry.mts b/packages/cli/src/cli-entry.mts index eba364335..f63729539 100755 --- a/packages/cli/src/cli-entry.mts +++ b/packages/cli/src/cli-entry.mts @@ -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' @@ -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' @@ -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' @@ -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({ @@ -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)) + debugDirNs('inspect', { error: e }) } await captureException(e) diff --git a/packages/cli/src/utils/error/display.mts b/packages/cli/src/utils/error/display.mts index 668151fa7..163befe25 100644 --- a/packages/cli/src/utils/error/display.mts +++ b/packages/cli/src/utils/error/display.mts @@ -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' @@ -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. @@ -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. diff --git a/packages/cli/src/utils/socket/api.mts b/packages/cli/src/utils/socket/api.mts index 15b8a0ed4..53acd468c 100644 --- a/packages/cli/src/utils/socket/api.mts +++ b/packages/cli/src/utils/socket/api.mts @@ -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' @@ -55,6 +54,7 @@ import { } from '../ecosystem/requirements.mts' import { buildErrorCause, + ConfigError, getNetworkErrorDiagnostics, } from '../error/errors.mts' @@ -383,7 +383,10 @@ export async function handleApiCallNoSpinner( 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( diff --git a/packages/cli/test/unit/utils/error/display.test.mts b/packages/cli/test/unit/utils/error/display.test.mts index 806c9fd93..ed323cb98 100644 --- a/packages/cli/test/unit/utils/error/display.test.mts +++ b/packages/cli/test/unit/utils/error/display.test.mts @@ -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') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 19cc1e347..063261e72 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -391,9 +391,6 @@ catalogs: open: specifier: 10.2.0 version: 10.2.0 - pony-cause: - specifier: 2.1.11 - version: 2.1.11 postject: specifier: 1.0.0-alpha.6 version: 1.0.0-alpha.6 @@ -702,9 +699,6 @@ importers: package-builder: specifier: workspace:* version: link:packages/package-builder - pony-cause: - specifier: 'catalog:' - version: 2.1.11 postject: specifier: 'catalog:' version: 1.0.0-alpha.6 @@ -900,9 +894,6 @@ importers: package-builder: specifier: workspace:* version: link:../package-builder - pony-cause: - specifier: 'catalog:' - version: 2.1.11 registry-auth-token: specifier: 'catalog:' version: 5.1.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a3d319646..ce9d7c6c9 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -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