Skip to content

Commit e90bc97

Browse files
authored
fix(cli): error messages in utils/dlx/ (#1256)
* fix(cli): align utils/dlx/ error messages with 4-ingredient strategy Rewrites error messages across packages/cli/src/utils/dlx/ to follow the What / Where / Saw vs. wanted / Fix strategy from CLAUDE.md. Sources: - spawn.mts: 27 messages - 6x 'Unexpected resolution type for <tool>' — now name the resolver function and the actual resolution.type seen - Archive/platform errors name the supported formats/platforms - Python DLX errors surface the lock file path and cache dir - PyPI fetch errors include the URL that failed - Security errors (zip-slip, symlink escape) tell user to delete the cached asset and report upstream - resolve-binary.mts: 4 messages (socket-patch, trivy, trufflehog, opengrep platform support) — each now lists supported platforms and suggests how to install the tool manually - vfs-extract.mts: 5 messages (SEA VFS extraction failures) — each names what went wrong with the bundle and how to recover (usually: rebuild SEA) Internal invariant errors stay as plain Error (not InputError) but are informative enough that if they ever fire, the user can open a useful bug report. Tests updated: test/unit/utils/dlx/resolve-binary.test.mts (1 substring match switched to regex). Follows strategy from #1254. Part of the multi-PR series started by #1255 (commands/). * fix(cli): hoist lockFile/pythonDir above retry check in ensurePythonDlx The previous commit referenced `${lockFile}` and `${pythonDir}` in the MAX_RETRIES error message, but those consts were declared AFTER the retry check. Hitting the retry path threw ReferenceError from the temporal dead zone instead of the intended InputError. Fix: move the three const declarations (pythonDir, pythonBin, lockFile) above the MAX_RETRIES guard. Caught by Cursor bugbot (#1256 (comment)) and confirmed by the type-check job. * chore(cli): harden (e as Error) casts to safe stringify Switch `(e as Error).message` to `e instanceof Error ? e.message : String(e)` so that when a non-Error value is thrown (strings, objects, null) the error message stays informative instead of becoming 'undefined'. Same fix as applied to #1260 (iocraft.mts) after Cursor bugbot flagged the pattern on that PR. * chore(cli): use joinAnd + getErrorCause helpers in dlx error messages Switch to shared fleet helpers so error lists render as human prose ('a, b, and c') and error-cause stringification works safely for non-Error throws (falls back to 'Unknown error' instead of crashing or producing 'undefined'). - resolve-binary.mts: SOCKET_PATCH_ASSETS + OPENGREP_ASSETS platform lists now use `joinAnd(Object.keys(...))`. - vfs-extract.mts: missingTools list uses joinAnd; extract-failure error now uses getErrorCause(e) — equivalent to the inline 'e instanceof Error ? e.message : String(e)' with the standard UNKNOWN_ERROR fallback. - spawn.mts: output-map listing uses joinAnd. No behavior change for Error throws; non-Error throws now produce 'Unknown error' instead of '[object Object]' or similar.
1 parent c2b1df9 commit e90bc97

4 files changed

Lines changed: 73 additions & 52 deletions

File tree

packages/cli/src/utils/dlx/resolve-binary.mts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
import os from 'node:os'
77

8+
import { joinAnd } from '@socketsecurity/lib/arrays'
9+
810
import { getCdxgenVersion } from '../../env/cdxgen-version.mts'
911
import { getCoanaVersion } from '../../env/coana-version.mts'
1012
import { requireOpengrepChecksum } from '../../env/opengrep-checksums.mts'
@@ -167,8 +169,7 @@ export function resolveSocketPatch(): BinaryResolution {
167169

168170
if (!assetName) {
169171
throw new Error(
170-
`socket-patch is not available for platform ${platformKey}. ` +
171-
`Supported platforms: ${Object.keys(SOCKET_PATCH_ASSETS).join(', ')}`,
172+
`socket-patch has no prebuilt binary for "${platformKey}" (supported: ${joinAnd(Object.keys(SOCKET_PATCH_ASSETS))}); upgrade socket-cli, build socket-patch from source, or set SOCKET_CLI_SOCKET_PATCH_LOCAL_PATH to point at a local build`,
172173
)
173174
}
174175

@@ -246,8 +247,7 @@ export function resolveTrivy(): BinaryResolution {
246247
const platform = os.platform()
247248
const arch = os.arch()
248249
throw new Error(
249-
`Trivy is not available for platform ${platform}-${arch}. ` +
250-
'Supported platforms: darwin-arm64, darwin-x64, linux-arm64, linux-x64, win32-x64',
250+
`Trivy has no prebuilt binary for "${platform}-${arch}" (supported: darwin-arm64, darwin-x64, linux-arm64, linux-x64, win32-x64); run socket-cli on a supported platform or install Trivy manually and point \`trivy\` at it on PATH`,
251251
)
252252
}
253253

@@ -310,8 +310,7 @@ export function resolveTrufflehog(): BinaryResolution {
310310
const platform = os.platform()
311311
const arch = os.arch()
312312
throw new Error(
313-
`TruffleHog is not available for platform ${platform}-${arch}. ` +
314-
'Supported platforms: darwin-arm64, darwin-x64, linux-arm64, linux-x64, win32-arm64, win32-x64',
313+
`TruffleHog has no prebuilt binary for "${platform}-${arch}" (supported: darwin-arm64, darwin-x64, linux-arm64, linux-x64, win32-arm64, win32-x64); run socket-cli on a supported platform or install TruffleHog manually and point \`trufflehog\` at it on PATH`,
315314
)
316315
}
317316

@@ -363,8 +362,7 @@ export function resolveOpengrep(): BinaryResolution {
363362

364363
if (!assetName) {
365364
throw new Error(
366-
`OpenGrep is not available for platform ${platformKey}. ` +
367-
`Supported platforms: ${Object.keys(OPENGREP_ASSETS).join(', ')}`,
365+
`OpenGrep has no prebuilt binary for "${platformKey}" (supported: ${joinAnd(Object.keys(OPENGREP_ASSETS))}); run socket-cli on a supported platform or install OpenGrep manually and point \`opengrep\` at it on PATH`,
368366
)
369367
}
370368

packages/cli/src/utils/dlx/spawn.mts

Lines changed: 55 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import os from 'node:os'
2222
import path from 'node:path'
2323

2424
import AdmZip from 'adm-zip'
25+
import { joinAnd } from '@socketsecurity/lib/arrays'
2526
import { WIN32 } from '@socketsecurity/lib/constants/platform'
2627
import { downloadBinary, getDlxCachePath } from '@socketsecurity/lib/dlx/binary'
2728
import { detectExecutableType } from '@socketsecurity/lib/dlx/detect'
@@ -116,14 +117,14 @@ function validatePackageName(name: string): void {
116117

117118
if (!validNamePattern.test(name)) {
118119
throw new InputError(
119-
`Invalid package name "${name}". Package names must contain only lowercase letters, numbers, hyphens, underscores, dots, and optionally a scope (@org/package).`,
120+
`package name "${name}" must match /^(@scope\\/)?[a-z0-9-~][a-z0-9-._~]*$/ (lowercase letters, digits, -, _, ., ~, with optional @scope/); rename the package or check for typos`,
120121
)
121122
}
122123

123124
// Check for path traversal attempts.
124125
if (name.includes('..') || (name.includes('/') && !name.startsWith('@'))) {
125126
throw new InputError(
126-
`Invalid package name "${name}". Package names cannot contain path traversal sequences.`,
127+
`package name "${name}" contains path traversal characters (".." or a "/" outside of @scope/); pass a plain name like "lodash" or "@org/pkg"`,
127128
)
128129
}
129130
}
@@ -232,7 +233,7 @@ async function downloadGitHubReleaseBinary(
232233
}
233234
}
234235
throw new InputError(
235-
'Timeout waiting for another process to download GitHub release',
236+
`timed out waiting for another socket process to finish downloading ${owner}/${repo}@${version} (${assetName}); if no other socket process is running, remove stale lock files under ${path.dirname(binaryPath)} and retry`,
236237
)
237238
}
238239
throw e
@@ -267,8 +268,7 @@ async function downloadGitHubReleaseBinary(
267268
const entryPath = path.resolve(path.join(cacheDir, entry.entryName))
268269
if (!entryPath.startsWith(normalizedCacheDir)) {
269270
throw new InputError(
270-
`Archive contains path traversal: ${entry.entryName}. ` +
271-
`This may indicate a compromised release asset.`,
271+
`archive entry "${entry.entryName}" resolves outside the cache dir (${normalizedCacheDir}) — this looks like a zip-slip attack; do NOT trust this release asset, report it to the upstream project, and delete ${result.binaryPath}`,
272272
)
273273
}
274274
}
@@ -286,8 +286,7 @@ async function downloadGitHubReleaseBinary(
286286
if (!resolvedTarget.startsWith(normalizedCacheDir)) {
287287
await fs.unlink(fullPath)
288288
throw new InputError(
289-
`Archive contains unsafe symbolic link: ${file}. ` +
290-
`This may indicate a compromised release asset.`,
289+
`extracted symlink ${file} targets ${resolvedTarget} which is outside the cache dir (${normalizedCacheDir}); do NOT trust this release asset, report it to the upstream project, and delete ${cacheDir}`,
291290
)
292291
}
293292
}
@@ -298,19 +297,20 @@ async function downloadGitHubReleaseBinary(
298297
const tarPath = await whichReal('tar', { nothrow: true })
299298
if (!tarPath || Array.isArray(tarPath)) {
300299
throw new InputError(
301-
'tar is required to extract GitHub release archives. Please install tar for your system.',
300+
`tar is required to extract ${assetName} but was not found on PATH; install tar (e.g. \`apt install tar\`, \`brew install gnu-tar\`) and re-run`,
302301
)
303302
}
304303
await spawn(tarPath, ['-xzf', result.binaryPath, '-C', cacheDir], {})
305304
} else {
306-
throw new InputError(`Unsupported archive format: ${assetName}`)
305+
throw new InputError(
306+
`archive format of ${assetName} is not supported (expected .zip or .tar.gz / .tgz); check the asset name in bundle-tools.json and the release's actual asset list`,
307+
)
307308
}
308309

309310
// Verify binary was extracted.
310311
if (!existsSync(binaryPath)) {
311312
throw new InputError(
312-
`Binary ${binaryFileName} not found after extracting ${assetName}. ` +
313-
`Expected at: ${binaryPath}`,
313+
`archive ${assetName} extracted but ${binaryFileName} was not found inside (expected at ${binaryPath}); the release's archive layout may have changed — verify asset contents and update bundle-tools.json`,
314314
)
315315
}
316316

@@ -408,7 +408,9 @@ export async function spawnCoanaDlx(
408408

409409
// Use dlx version (resolveCoana only returns 'local' or 'dlx' types).
410410
if (resolution.type !== 'dlx') {
411-
throw new Error('Unexpected resolution type for coana')
411+
throw new Error(
412+
`internal: resolveCoana returned resolution.type="${resolution.type}" (expected "dlx"); this is a resolver contract bug — re-run with --debug and report the output`,
413+
)
412414
}
413415
const result = await spawnDlx(
414416
{
@@ -484,7 +486,9 @@ export async function spawnCdxgenDlx(
484486

485487
// Use dlx version (resolveCdxgen only returns 'local' or 'dlx' types).
486488
if (resolution.type !== 'dlx') {
487-
throw new Error('Unexpected resolution type for cdxgen')
489+
throw new Error(
490+
`internal: resolveCdxgen returned resolution.type="${resolution.type}" (expected "dlx"); this is a resolver contract bug — re-run with --debug and report the output`,
491+
)
488492
}
489493
return await spawnDlx(
490494
resolution.details,
@@ -554,7 +558,9 @@ export async function spawnSfwDlx(
554558

555559
// Use dlx version (resolveSfw only returns 'local' or 'dlx' types).
556560
if (resolution.type !== 'dlx') {
557-
throw new Error('Unexpected resolution type for sfw')
561+
throw new Error(
562+
`internal: resolveSfw returned resolution.type="${resolution.type}" (expected "dlx"); this is a resolver contract bug — re-run with --debug and report the output`,
563+
)
558564
}
559565
return await spawnDlx(
560566
resolution.details,
@@ -675,21 +681,25 @@ async function spawnToolVfs(
675681
): Promise<DlxSpawnResult> {
676682
if (!areExternalToolsAvailable()) {
677683
throw new Error(
678-
`Cannot spawn ${tool} from VFS - tools not available in SEA mode`,
684+
`cannot spawn ${tool} from VFS: external tools were not bundled into this SEA binary; rebuild the SEA with INLINED_SOCKET_CLI_INCLUDE_EXTERNAL_TOOLS=1 or run the non-SEA CLI`,
679685
)
680686
}
681687

682688
// Extract tools from VFS (returns paths directly).
683689
const toolPaths = await extractExternalTools()
684690
if (!toolPaths) {
685-
throw new Error(`Failed to extract ${tool} from VFS`)
691+
throw new Error(
692+
`failed to extract ${tool} from VFS (extractExternalTools returned null); the embedded tool archive may be corrupt — rebuild the SEA binary`,
693+
)
686694
}
687695

688696
// Get tool path.
689697
const toolPath = toolPaths[tool]
690698

691699
if (!toolPath) {
692-
throw new Error(`Tool path not found for ${tool}`)
700+
throw new Error(
701+
`VFS extraction succeeded but ${tool} was not in the output map (got: ${joinAnd(Object.keys(toolPaths)) || 'empty'}); the SEA bundle is missing ${tool} — rebuild with it included`,
702+
)
693703
}
694704

695705
const { env: spawnEnv, ...dlxOptions } = {
@@ -938,7 +948,9 @@ function getPythonStandaloneInfo(): { assetName: string; url: string } {
938948
platformTriple =
939949
arch === 'arm64' ? 'aarch64-pc-windows-msvc' : 'x86_64-pc-windows-msvc'
940950
} else {
941-
throw new InputError(`Unsupported platform: ${platform}`)
951+
throw new InputError(
952+
`python-build-standalone does not ship a prebuilt for os.platform()="${platform}" (supported: darwin, linux, win32); install Python manually and point socket at it via PATH`,
953+
)
942954
}
943955

944956
// Asset name format matches checksums in bundle-tools.json.
@@ -1000,7 +1012,7 @@ async function downloadPython(pythonDir: string): Promise<void> {
10001012
const tarPath = await whichReal('tar', { nothrow: true })
10011013
if (!tarPath || Array.isArray(tarPath)) {
10021014
throw new InputError(
1003-
'tar is required to extract Python. Please install tar for your system.',
1015+
`tar is required to extract the Python standalone archive but was not found on PATH; install tar (e.g. \`apt install tar\`, \`brew install gnu-tar\`) and re-run`,
10041016
)
10051017
}
10061018
await spawn(tarPath, ['-xzf', result.binaryPath, '-C', pythonDir], {})
@@ -1044,17 +1056,16 @@ export async function ensurePython(): Promise<string> {
10441056
export async function ensurePythonDlx(retryCount = 0): Promise<string> {
10451057
const MAX_RETRIES = 3
10461058

1059+
const pythonDir = getPythonCachePath()
1060+
const pythonBin = getPythonBinPath(pythonDir)
1061+
const lockFile = path.join(pythonDir, '.downloading')
1062+
10471063
if (retryCount >= MAX_RETRIES) {
10481064
throw new InputError(
1049-
`Failed to acquire Python installation lock after ${MAX_RETRIES} retries. ` +
1050-
'Please check for filesystem issues or competing processes.',
1065+
`could not acquire the Python install lock after ${MAX_RETRIES} retries at ${lockFile}; another socket process may be stuck, or the lock file is stale — remove it manually and retry, or check that ${pythonDir} is writable`,
10511066
)
10521067
}
10531068

1054-
const pythonDir = getPythonCachePath()
1055-
const pythonBin = getPythonBinPath(pythonDir)
1056-
const lockFile = path.join(pythonDir, '.downloading')
1057-
10581069
if (!existsSync(pythonBin)) {
10591070
await safeMkdir(pythonDir, { recursive: true })
10601071

@@ -1107,7 +1118,7 @@ export async function ensurePythonDlx(retryCount = 0): Promise<string> {
11071118
}
11081119
}
11091120
throw new InputError(
1110-
'Timeout waiting for Python download by another process',
1121+
`timed out after 60s waiting for another socket process to finish downloading Python to ${pythonDir}; if no other socket process is running, remove ${lockFile} and retry`,
11111122
)
11121123
}
11131124
throw e
@@ -1118,7 +1129,7 @@ export async function ensurePythonDlx(retryCount = 0): Promise<string> {
11181129

11191130
if (!existsSync(pythonBin)) {
11201131
throw new InputError(
1121-
`Python binary not found after extraction: ${pythonBin}`,
1132+
`Python archive extracted but ${pythonBin} does not exist; the standalone archive layout may have changed — check the asset contents under ${pythonDir} and update the bin-path logic in spawn.mts`,
11221133
)
11231134
}
11241135

@@ -1218,7 +1229,9 @@ async function downloadPyPiWheel(
12181229
try {
12191230
const response = await socketHttpRequest(pypiUrl)
12201231
if (!response.ok) {
1221-
throw new Error(`PyPI API returned ${response.status}`)
1232+
throw new Error(
1233+
`PyPI returned HTTP ${response.status} for ${pypiUrl} (expected 200); check the package name and version, or retry if the registry is rate-limiting`,
1234+
)
12221235
}
12231236
const data = response.json() as {
12241237
urls?: Array<{ filename: string; url: string }>
@@ -1235,14 +1248,13 @@ async function downloadPyPiWheel(
12351248
// If we can't fetch from API, construct URL directly (may not work for all packages).
12361249
// This is a fallback; the API approach is more reliable.
12371250
throw new InputError(
1238-
`Failed to fetch PyPI package info for ${packageName}@${version}: ${getErrorCause(e)}`,
1251+
`could not fetch PyPI metadata for ${packageName}==${version} from ${pypiUrl} (${getErrorCause(e)}); check your network or proxy settings, or try again if PyPI is rate-limiting`,
12391252
)
12401253
}
12411254

12421255
if (!wheelUrl) {
12431256
throw new InputError(
1244-
`No wheel found for ${packageName}@${version} on PyPI. ` +
1245-
'This package may only be available as a source distribution.',
1257+
`${packageName}==${version} has no py3-none-any wheel on PyPI (only sdist available); pin to a version that ships a wheel or install from source manually`,
12461258
)
12471259
}
12481260

@@ -1275,8 +1287,7 @@ export async function ensureSocketPyCli(
12751287

12761288
if (retryCount >= MAX_RETRIES) {
12771289
throw new InputError(
1278-
`Failed to acquire Socket Python CLI installation lock after ${MAX_RETRIES} retries. ` +
1279-
'Please check for filesystem issues or competing processes.',
1290+
`could not acquire the Socket Python CLI install lock after ${MAX_RETRIES} retries; another socket process may be stuck, or the lock file is stale — check for stale lock files under the Python cache dir and retry`,
12801291
)
12811292
}
12821293

@@ -1386,7 +1397,7 @@ export async function ensureSocketPyCli(
13861397
})
13871398
} else {
13881399
throw new InputError(
1389-
`Failed to download verified socketsecurity wheel for version ${pyCliVersion}`,
1400+
`could not download the verified socketsecurity==${pyCliVersion} wheel (downloadPyPiWheel returned null — likely a checksum mismatch or missing wheel asset); re-run with --debug for details, or bump the version in bundle-tools.json if the checksum needs refreshing`,
13901401
)
13911402
}
13921403
} else {
@@ -1454,7 +1465,7 @@ export async function spawnSocketPyCliVfs(
14541465
})
14551466
} else {
14561467
throw new Error(
1457-
`Failed to download verified socketsecurity wheel for version ${pyCliVersion}`,
1468+
`failed to download socketsecurity==${pyCliVersion} wheel from PyPI (downloadPyPiWheel returned null — likely a checksum mismatch or missing py3-none-any wheel); re-run with --debug for details`,
14581469
)
14591470
}
14601471
} else {
@@ -1673,7 +1684,9 @@ async function spawnTrivyDlx(
16731684
const resolution = resolveTrivy()
16741685

16751686
if (resolution.type !== 'github-release') {
1676-
throw new Error('Unexpected resolution type for trivy')
1687+
throw new Error(
1688+
`internal: resolveTrivy returned resolution.type="${resolution.type}" (expected "github-release"); this is a resolver contract bug — re-run with --debug and report the output`,
1689+
)
16771690
}
16781691

16791692
const { env: spawnEnv, ...dlxOptions } = {
@@ -1735,7 +1748,9 @@ async function spawnTrufflehogDlx(
17351748
const resolution = resolveTrufflehog()
17361749

17371750
if (resolution.type !== 'github-release') {
1738-
throw new Error('Unexpected resolution type for trufflehog')
1751+
throw new Error(
1752+
`internal: resolveTrufflehog returned resolution.type="${resolution.type}" (expected "github-release"); this is a resolver contract bug — re-run with --debug and report the output`,
1753+
)
17391754
}
17401755

17411756
const { env: spawnEnv, ...dlxOptions } = {
@@ -1797,7 +1812,9 @@ async function spawnOpengrepDlx(
17971812
const resolution = resolveOpengrep()
17981813

17991814
if (resolution.type !== 'github-release') {
1800-
throw new Error('Unexpected resolution type for opengrep')
1815+
throw new Error(
1816+
`internal: resolveOpengrep returned resolution.type="${resolution.type}" (expected "github-release"); this is a resolver contract bug — re-run with --debug and report the output`,
1817+
)
18011818
}
18021819

18031820
const { env: spawnEnv, ...dlxOptions } = {

0 commit comments

Comments
 (0)