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
9 changes: 9 additions & 0 deletions .changeset/native-binary-guards.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"stash": minor
---

Add guards for missing native binaries. When npm skips the platform-specific
optional dependency (a known npm bug), stash now prints actionable fix
guidance instead of a raw `MODULE_NOT_FOUND` stack trace. Adds a new
`stash doctor` command that diagnoses the runtime and native modules and works
even when a binary is missing.
75 changes: 75 additions & 0 deletions packages/cli/src/__tests__/native.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest'
import { isNativeBinaryMissing } from '../native.js'

interface ModuleError extends Error {
code?: string
requireStack?: string[]
}

function moduleError(message: string, requireStack: string[] = []): ModuleError {
const err = new Error(message) as ModuleError
err.code = 'MODULE_NOT_FOUND'
err.requireStack = requireStack
return err
}

describe('isNativeBinaryMissing', () => {
it('matches a missing platform-specific protect-ffi binary', () => {
// The real-world failure: npm skipped the optional native dependency.
const err = moduleError(
"Cannot find module '@cipherstash/protect-ffi-darwin-arm64'",
[
'/x/node_modules/@cipherstash/protect-ffi/lib/load.cjs',
'/x/node_modules/@cipherstash/protect-ffi/lib/index.cjs',
],
)
expect(isNativeBinaryMissing(err)).toBe(true)
})

it('matches the auth native binary on linux/windows targets', () => {
expect(
isNativeBinaryMissing(
moduleError("Cannot find module '@cipherstash/auth-linux-x64-gnu'"),
),
).toBe(true)
expect(
isNativeBinaryMissing(
moduleError("Cannot find module '@cipherstash/auth-win32-x64-msvc'"),
),
).toBe(true)
})

it('matches when only the neon loader appears in the require stack', () => {
const err = moduleError('Cannot find module somewhere', [
'/x/node_modules/@neon-rs/load/dist/index.js',
])
expect(isNativeBinaryMissing(err)).toBe(true)
})

it('does not match a missing top-level package', () => {
expect(
isNativeBinaryMissing(
moduleError("Cannot find module '@cipherstash/stack'"),
),
).toBe(false)
})

it('does not match unrelated module errors', () => {
expect(
isNativeBinaryMissing(moduleError("Cannot find module 'left-pad'")),
).toBe(false)
})

it('does not match errors without a module-not-found code', () => {
const err = new Error(
'Cannot find module @cipherstash/protect-ffi-darwin-arm64',
) as ModuleError
err.code = 'EACCES'
expect(isNativeBinaryMissing(err)).toBe(false)
})

it('ignores non-Error values', () => {
expect(isNativeBinaryMissing(undefined)).toBe(false)
expect(isNativeBinaryMissing('boom')).toBe(false)
})
})
Loading
Loading