Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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