Skip to content

feat(cli): guard against missing native binaries#516

Open
coderdan wants to merge 3 commits into
mainfrom
cli/native-binary-guards
Open

feat(cli): guard against missing native binaries#516
coderdan wants to merge 3 commits into
mainfrom
cli/native-binary-guards

Conversation

@coderdan

@coderdan coderdan commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Background

npx stash@latest auth login can crash with a raw, unactionable stack trace:

Error: Cannot find module '@cipherstash/protect-ffi-darwin-arm64'
  ... MODULE_NOT_FOUND

stash loads native Rust addons — protect-ffi (via @cipherstash/stack) and @cipherstash/auth — distributed as per-platform optional npm packages (@cipherstash/<pkg>-<platform>-<arch>) and selected at runtime by @neon-rs/load. npm intermittently skips installing these optional dependencies (npm/cli#4828), typically after a partial/stale cache (~/.npm/_npx is a common culprit). The base package is present but the platform binary is missing.

The packaging is correct (all platforms published, versions pinned) — the failure is npm-side install flakiness made worse by an unfriendly error. These guards turn that into a self-explaining, recoverable experience. Interim hardening ahead of the planned Rust/Homebrew CLI.

What this does

1. Friendly error guard. The bin is split into a thin launcher (bin/stash.ts, ~1 KB) that dynamically imports the command graph (bin/main.ts). The native addons are pulled in eagerly during that module's evaluation, so the dynamic import lets us wrap evaluation in a try/catch. A missing native binary is detected and rendered as actionable guidance instead of a stack trace:

■  stash couldn't load its native module for this platform.

◇  Native module not found ───────────────────────────────╮
│  Missing package: @cipherstash/protect-ffi-darwin-arm64 │
│  Platform:        darwin-arm64                          │
│  ... npm/cli#4828 ...                                   │
│  Fix it with one of:                                    │
│    rm -rf "$(npm config get cache)/_npx" && npx ...     │
│    rm -rf node_modules package-lock.json && npm install │
│    npm install -g stash@latest --force                  │
│  Then run `stash doctor` to confirm.                    │
╰─────────────────────────────────────────────────────────╯

2. stash doctor. New command that checks the Node runtime, platform, and force-loads each native-bearing package, reporting a per-check status. Dispatched by the launcher before the command graph loads, so it runs even when a binary is missing — i.e. exactly when you need to diagnose.

3. src/native.ts. isNativeBinaryMissing() (distinguishes a missing platform binary from a missing top-level package or any other module error) + reportNativeBinaryMissing(). Unit-tested.

Files

  • src/bin/stash.ts — now a thin launcher (dynamic import + native-error catch)
  • src/bin/main.ts — the former stash.ts body, exported as run()
  • src/native.ts — detection + guidance helpers
  • src/commands/doctor/index.tsstash doctor
  • src/__tests__/native.test.ts — detection tests

Verification

  • pnpm test319 passed (7 new)
  • pnpm run build — clean; entry stash.js is 983 B and loads main (93 KB) as a separate chunk; entry contains zero protect-ffi/auth references (native graph no longer eagerly bundled into the entry)
  • stash --version, stash doctor (healthy) verified against the built binary
  • Friendly guidance rendering verified against a simulated missing-binary error

Notes

  • Repo-wide biome lint currently fails for an unrelated reason: root pins @biomejs/biome@^2.4.15 but biome.json still uses the 1.8.3 schema (files.ignore was renamed to files.includes in biome 2.x). Not touched here; worth a separate fix. New files follow the existing style (single quotes, no semicolons, 2-space).

Summary by CodeRabbit

  • New Features
    • Added stash doctor to run a health check, report the current platform target, and verify required runtime components.
  • Bug Fixes
    • Improved handling for missing native binaries by replacing raw module-not-found errors with actionable install/rerun guidance.
  • Tests
    • Added end-to-end tests for stash doctor, plus unit coverage for native-binary missing detection.
  • Documentation
    • Updated CLI messages to include the stash doctor success UI text.

stash loads native Rust addons (protect-ffi via @cipherstash/stack, and
@cipherstash/auth) distributed as per-platform optional npm packages. npm
intermittently skips installing these optional deps (npm/cli#4828), leaving
the base package present but the platform binary missing. The failure was a
raw MODULE_NOT_FOUND stack trace with no guidance.

- Split the bin into a thin launcher (stash.ts) that dynamically imports the
  command graph (main.ts), so a native-load failure during module evaluation
  is caught and rendered as actionable fix guidance instead of a stack trace.
- Add src/native.ts: isNativeBinaryMissing() detection + reportNativeBinaryMissing()
  guidance, with unit tests.
- Add 'stash doctor' to diagnose runtime + native modules. Dispatched before
  the command graph loads so it runs even when a binary is missing.

Interim hardening ahead of the planned Rust/Homebrew CLI.
@coderdan coderdan requested a review from a team as a code owner June 17, 2026 03:08
@changeset-bot

changeset-bot Bot commented Jun 17, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 646354b

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
stash Minor
@cipherstash/e2e Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai

coderabbitai Bot commented Jun 17, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c0821b0f-2e9c-4166-ad99-74e218ec8397

📥 Commits

Reviewing files that changed from the base of the PR and between 571e55f and 646354b.

📒 Files selected for processing (4)
  • packages/cli/src/commands/doctor/index.ts
  • packages/cli/src/messages.ts
  • packages/cli/src/native.ts
  • packages/cli/tests/e2e/doctor.e2e.test.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/cli/tests/e2e/doctor.e2e.test.ts
  • packages/cli/src/native.ts

📝 Walkthrough

Walkthrough

Adds packages/cli/src/native.ts with helpers to detect and report missing platform-specific native addon binaries. Introduces a new stash doctor command that validates Node version, platform target, and probes required packages. Extracts all CLI logic from stash.ts into a new main.ts module and refactors stash.ts into a thin launcher that guards against native binary failures at import and call time.

Changes

Native Binary Guards and stash doctor Command

Layer / File(s) Summary
Native binary detection utilities and tests
packages/cli/src/native.ts, packages/cli/src/__tests__/native.test.ts, .changeset/native-binary-guards.md
Adds currentTarget(), isNativeBinaryMissing(), missingModuleName(), reinstallCommand(), and reportNativeBinaryMissing() to detect and classify missing platform-suffixed optional native addon binaries. Tests verify matching behavior for @cipherstash/protect-ffi (Darwin ARM64), @cipherstash/auth (Linux/Windows), @neon-rs/load patterns, and all negative cases. Changeset documents the feature as a minor release.
stash doctor command implementation
packages/cli/src/commands/doctor/index.ts, packages/cli/src/messages.ts, packages/cli/tests/e2e/doctor.e2e.test.ts
Adds doctorCommand() which validates Node.js major version (≥22), reports the platform target, and iterates over PROBES using dynamic imports to check required and optional packages. Distinguishes native-binary-missing errors from top-level package missing errors via isNativeBinaryMissing and isPackageMissing helpers, exits with code 1 only when required probes fail, and prints "All checks passed" on success. Extends messages.ts with cli.doctor strings for output. E2E tests verify correct dispatch and healthy-install behavior.
CLI logic extracted into main.ts
packages/cli/src/bin/main.ts
New file containing the full CLI body: dotenv precedence loading (.env.local, .env.development.local, .env), version and package-manager detection, requireStack helper that converts ERR_MODULE_NOT_FOUND for @cipherstash/stack into actionable output, HELP banner documenting all commands and options, parseArgs to extract command/subcommand/flags/values, subcommand dispatchers (runDbCommand, runEncryptCommand, runSchemaCommand), requireValue validation, and the exported run() dispatcher routing all top-level commands.
stash.ts refactored to thin launcher
packages/cli/src/bin/stash.ts
Refactors from 443 lines of full CLI implementation to a 29-line bootstrap that routes doctor directly via dynamic import, then loads ./main.js for all other commands. Wraps both import-time and call-time errors to detect native-addon failures via isNativeBinaryMissing and report them via reportNativeBinaryMissing before exiting with code 1.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant stash.ts as stash.ts (launcher)
    participant native as native.ts
    participant main as bin/main.ts
    participant doctor as commands/doctor

    User->>stash.ts: run stash <command>

    alt command === "doctor"
        stash.ts->>doctor: import() + doctorCommand()
        doctor->>native: isNativeBinaryMissing() / reportNativeBinaryMissing()
        doctor-->>User: health report + exit code
    else other commands
        stash.ts->>main: import() + run()
        alt import throws (native binary missing)
            main-->>stash.ts: throws ModuleError
            stash.ts->>native: isNativeBinaryMissing(err)
            stash.ts->>native: reportNativeBinaryMissing(err)
            stash.ts-->>User: actionable error + exit 1
        else import succeeds
            main->>main: parseArgs + dispatch subcommand
            main-->>User: command output
        end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

  • cipherstash/stack#323: The main PR's native-binary missing guidance reuses prodInstallCommand to print install commands, which this PR introduces for use by stash/stack init flows.
  • cipherstash/stack#339: Both PRs change the CLI boot/dispatch to lazily load @cipherstash/stack-dependent commands and use requireStack/missing-module detection in packages/cli/src/bin/stash.ts, though the main PR extends this with native-binary guards and a new stash doctor command.
  • cipherstash/stack#394: Both PRs touch the CLI entrypoint/dispatch surface in packages/cli/src/bin/stash.ts: the main PR refactors it to dynamically load main.js and route doctor with native-missing guards, while the retrieved PR adds the stash wizard subcommand routing and help wiring.

Suggested reviewers

  • auxesis
  • tobyhede

🐇 A rabbit hopped through the terminal one day,
Found no native binary blocking the way —
stash doctor now checks what's missing or wrong,
With platform-aware guards to keep things along.
No more raw stack traces to leave you astray! 🌿

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 27.78% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(cli): guard against missing native binaries' accurately summarizes the main objective of the PR, which is to add safeguards and error handling for missing native binary dependencies in the stash CLI.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch cli/native-binary-guards

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/cli/src/bin/stash.ts (1)

15-46: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Add required E2E coverage for the launcher error-handling path

This PR changes stash.ts top-level error handling, but no E2E test is included in the provided changes to validate argv/exit-code behavior for the new launcher path.

As per coding guidelines, packages/cli/src/bin/stash.ts: “Add an E2E test when touching src/bin/stash.ts argv parsing, exit codes, or top-level error handling”.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/bin/stash.ts` around lines 15 - 46, The bootstrap function
in stash.ts has been modified to add top-level error handling with the
isNativeBinaryMissing check and reportNativeBinaryMissing call, but no E2E test
has been added to validate this behavior. Add an E2E test that covers the
error-handling paths in the bootstrap function, specifically validating that the
correct exit codes and error messages are returned when isNativeBinaryMissing
conditions are met, and also when general errors occur in the catch handler that
wraps the bootstrap() call.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/cli/src/bin/stash.ts`:
- Around line 36-45: The bootstrap error handler in the catch block is logging
the raw error message directly via p.log.error(`Fatal error: ${message}`), which
risks exposing sensitive data like credentials or tokens. Replace the error
logging to use only a generic error message without including the actual error
details from err.message or String(err). Remove the interpolation of the message
variable and log a generic fatal error message instead.

---

Outside diff comments:
In `@packages/cli/src/bin/stash.ts`:
- Around line 15-46: The bootstrap function in stash.ts has been modified to add
top-level error handling with the isNativeBinaryMissing check and
reportNativeBinaryMissing call, but no E2E test has been added to validate this
behavior. Add an E2E test that covers the error-handling paths in the bootstrap
function, specifically validating that the correct exit codes and error messages
are returned when isNativeBinaryMissing conditions are met, and also when
general errors occur in the catch handler that wraps the bootstrap() call.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 59e1fc26-bed9-4515-ac95-81722404e607

📥 Commits

Reviewing files that changed from the base of the PR and between 917b5c0 and eb94ac8.

📒 Files selected for processing (6)
  • .changeset/native-binary-guards.md
  • packages/cli/src/__tests__/native.test.ts
  • packages/cli/src/bin/main.ts
  • packages/cli/src/bin/stash.ts
  • packages/cli/src/commands/doctor/index.ts
  • packages/cli/src/native.ts

Comment thread packages/cli/src/bin/stash.ts

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves the stash CLI’s resilience when npm fails to install platform-specific optional native binaries (e.g. @cipherstash/protect-ffi-<platform>-<arch>), replacing raw module-load stack traces with actionable guidance and adding a stash doctor diagnostic command that can run even when native binaries are missing.

Changes:

  • Split the CLI entry into a thin launcher (bin/stash.ts) and dynamically-imported body (bin/main.ts) to catch native module load failures and render friendly remediation guidance.
  • Add stash doctor to probe runtime/platform and force-load native-bearing packages, reporting per-check status.
  • Introduce src/native.ts helpers to detect missing native optional packages and format user guidance, with unit tests and a Changeset for the CLI release.

Reviewed changes

Copilot reviewed 4 out of 6 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
packages/cli/src/native.ts Adds native-missing detection and user-facing guidance helpers.
packages/cli/src/commands/doctor/index.ts Implements stash doctor probes and reporting logic.
packages/cli/src/bin/stash.ts New thin launcher that intercepts native-missing errors and dispatches doctor early.
packages/cli/src/bin/main.ts Moves the CLI command graph and help text into a dynamically imported module; adds doctor wiring.
packages/cli/src/tests/native.test.ts Adds Vitest coverage for native-missing detection.
.changeset/native-binary-guards.md Declares a minor version bump for the new guards + doctor command.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/cli/src/native.ts
…r E2E

- native.ts: build the re-run command via runnerCommand(detectPackageManager())
  instead of a hardcoded `npx`, fixing the lint:runners CI failure and making
  the guidance correct for bun/pnpm/yarn. The npx cache-clear hint is now shown
  only for npm.
- missingModuleName(): extract the real platform package from the message/
  requireStack (so Linux's libc suffix like -linux-x64-gnu is preserved) and
  parse the ESM 'Cannot find package' form, not just CJS 'Cannot find module'.
  Fallback wording no longer implies a specific package suffix. (Copilot review)
- Add tests/e2e/doctor.e2e.test.ts covering the launcher's doctor dispatch and
  exit code, per the src/bin/stash.ts coding guideline.
@coderdan

Copy link
Copy Markdown
Contributor Author

Pushed 571e55f addressing the review + the failing lint:runners check.

CI failure (lint:runners) — fixed. native.ts hardcoded npx stash@latest, which the no-hardcoded-runners lint forbids. The guidance now builds the re-run command via runnerCommand(detectPackageManager(), 'stash@latest'), so it's correct for npm/bun/pnpm/yarn (e.g. bunx …, pnpm dlx …) and the npx cache-clear hint is shown only for npm. node scripts/lint-no-hardcoded-runners.mjs now passes.

@copilot — native.ts:65 (good catch) — fixed. missingModuleName() now (a) extracts the real platform package from the message and requireStack via the @cipherstash/<pkg>-<platform>-… regex, so the Linux libc/toolchain suffix (-linux-x64-gnu) is preserved, and (b) parses both the CJS Cannot find module '…' and ESM Cannot find package '…' forms. The fallback wording no longer implies a specific package suffix (No native binary was loaded for <target>.).

E2E coverage (bin guideline) — added. tests/e2e/doctor.e2e.test.ts drives the built binary through the launcher's early doctor dispatch, asserting exit code 0 and that it doesn't fall through to the unknown-command path. Full E2E suite: 23 passed.

@coderabbitai — stash.ts:45 (logging err.message) — keeping, with reasoning. This is the last-resort fatal handler, and logging the exception message is the established pattern across the CLI (init/steps/install-deps.ts, commands/wizard/index.ts, db/install.ts, encrypt/cutover.ts, etc. all log err.message). The cited guideline is about the encryption library never logging plaintext sensitive values, not exception messages. Gating only this one call site behind a new STASH_DEBUG flag would be inconsistent and wouldn't address the other sites that actually touch DATABASE_URL. If redacting exception output is desired, it should be a deliberate codebase-wide helper in a separate PR rather than a one-off here.

@coderabbitai

coderabbitai Bot commented Jun 17, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@coderdan — thanks for the thorough follow-up and the clear reasoning on each point.

  • lint:runners — the runnerCommand(detectPackageManager(), ...) approach is the right fix; good to see it passing.
  • missingModuleName() — extracting from both message and requireStack, preserving the full GNU suffix, and handling both CJS/ESM error forms all look like solid improvements.
  • E2E doctor coverage — appreciated; asserting the early-dispatch path and exit code 0 closes the bin-guideline gap cleanly.
  • err.message at stash.ts:45 — your reasoning is sound. The guideline targets the encryption library's handling of plaintext data, not general exception messages in CLI bootstrap code. Keeping it consistent with the established pattern across init/steps/install-deps.ts, commands/wizard/index.ts, db/install.ts, and encrypt/cutover.ts is the right call; a codebase-wide redaction helper belongs in a separate PR. I'm withdrawing that concern.

✏️ Learnings added
Learnt from: coderdan
URL: https://github.com/cipherstash/stack/pull/516

Timestamp: 2026-06-17T04:23:59.612Z
Learning: In the `packages/cli` package of the `cipherstash/stack` repo, logging `err.message` in catch blocks is an established pattern across CLI commands (e.g. `init/steps/install-deps.ts`, `commands/wizard/index.ts`, `db/install.ts`, `encrypt/cutover.ts`). The project guideline about not logging plaintext sensitive values applies specifically to the encryption library (e.g. `cipherstash/protect`), not to exception messages surfaced in CLI error handlers. Flagging individual CLI catch-block `err.message` logs against this guideline is a false positive; any codebase-wide redaction approach should be a deliberate separate PR.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/cli/src/native.ts`:
- Around line 63-74: The reinstallCommand function uses POSIX-only shell
commands (rm -rf) that will fail on Windows platforms, even though win32 is an
explicitly supported target. Modify the reinstallCommand function to generate
platform-appropriate recovery commands by detecting the operating system and
returning either POSIX syntax (for Unix-like systems) or Windows-compatible
syntax (using del, rmdir for Windows). Ensure all package manager recovery
commands in the switch statement (for bun, pnpm, yarn, and npm) are adapted to
work correctly on both Windows and Unix-like platforms.

In `@packages/cli/tests/e2e/doctor.e2e.test.ts`:
- Around line 12-15: The test file has hard-coded user-facing strings in expect
assertions on multiple lines (the strings 'stash doctor', the platform string
template, and 'All checks passed'). Replace these hard-coded strings with
imported message constants from src/messages.ts. Import the appropriate message
constants from that file and update the expect statements to assert against
those constants instead of the literal strings, ensuring test expectations
remain synchronized with any future changes to the CLI copy.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 92a40c9f-9b70-4752-aea9-48e57d42ba49

📥 Commits

Reviewing files that changed from the base of the PR and between eb94ac8 and 571e55f.

📒 Files selected for processing (2)
  • packages/cli/src/native.ts
  • packages/cli/tests/e2e/doctor.e2e.test.ts

Comment thread packages/cli/src/native.ts Outdated
Comment thread packages/cli/tests/e2e/doctor.e2e.test.ts Outdated
…ages

- native.ts: reinstall/cache-clear guidance now emits PowerShell on win32
  (Remove-Item) instead of non-runnable POSIX (rm -rf/$(...)), since win32 is a
  supported native target. (CodeRabbit)
- Extract doctor's asserted user-facing strings into messages.doctor (title,
  platformLabel, allChecksPassed); doctor and its E2E test reference the
  constants so copy changes stay in one place. (CodeRabbit)
@coderdan

Copy link
Copy Markdown
Contributor Author

Both addressed in 646354b.

native.ts:74 — Windows guidance (good catch). win32 is a supported native target, so the recovery commands are now shell-aware: PowerShell on Windows (Remove-Item -Recurse -Force …) and POSIX elsewhere — for both the reinstall step and the npm _npx cache-clear. Verified the rendered win32 output uses runnable PowerShell, and the package name is the real @cipherstash/protect-ffi-win32-x64-msvc (from the earlier ESM/requireStack extraction fix).

doctor.e2e.test.ts — extract asserted strings. Added a doctor section to src/messages.ts (title, platformLabel, allChecksPassed); commands/doctor/index.ts renders from those constants and the E2E test asserts against them (plus the existing messages.cli.unknownCommand). No more hard-coded copy in the test.

Verified: lint:runners passes, 319 unit + doctor E2E green.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants