Skip to content

fix(cli): unwrap default export when loading stash.config.ts#375

Merged
calvinbrewer merged 2 commits intomainfrom
dan/fix-stash-db-install-databaseurl
Apr 30, 2026
Merged

fix(cli): unwrap default export when loading stash.config.ts#375
calvinbrewer merged 2 commits intomainfrom
dan/fix-stash-db-install-databaseurl

Conversation

@coderdan
Copy link
Copy Markdown
Contributor

@coderdan coderdan commented Apr 30, 2026

Closes #374.

Symptom

$ npx @cipherstash/cli db install
Error: Invalid stash.config.ts
  - databaseUrl: Invalid input: expected nonoptional, received undefined

Even when the user's stash.config.ts clearly sets databaseUrl from process.env.DATABASE_URL, and a console.log of the same env var prints the URL just before the error fires.

Root cause

loadStashConfig (packages/cli/src/config/index.ts) was constructing jiti with interopDefault: true and then calling jiti.import(...):

const jiti = createJiti(configPath, { interopDefault: true })
const rawConfig = await jiti.import(configPath)   // ← option silently ignored

In jiti 2.x, the constructor's interopDefault only applies to the deprecated synchronous jiti(id) callable form. The async jiti.import() ignores it and always returns the module namespace. So for export default defineConfig({...}), rawConfig is { default: { databaseUrl, client } }. Zod then validates the wrapper, finds no top-level databaseUrl, and emits the misleading "expected nonoptional, received undefined" error.

Verified empirically against jiti@2.6.1:

> jiti.import(configPath)                       → {"default":{"databaseUrl":"..."}}
> jiti.import(configPath, { default: true })    → {"databaseUrl":"..."}

Fix

Switch to the per-call { default: true } option (the jiti 2.x async-API way to ask for default-export unwrapping). Drop the now-misleading interopDefault: true from both loadStashConfig and the symmetric loadEncryptConfig call site (which wasn't symptom-bugged because it iterates Object.values to find the EncryptionClient — but consistency keeps the next reader from reaching the same wrong conclusion).

Why the existing test missed it

packages/cli/src/__tests__/config.test.ts mocks jiti.import to return the already-unwrapped shape:

mockJiti.import = vi.fn().mockResolvedValue({ databaseUrl: '...' })

So the test never exercised the real jiti behavior and would pass regardless of which option was set.

Regression test

New src/__tests__/config-jiti-integration.test.ts drives loadStashConfig against real jiti and a real stash.config.ts file written into a temp dir. Two cases:

  1. export default {...} is unwrapped to the inner config (the regression we're fixing).
  2. A genuinely-missing databaseUrl produces a useful error message containing Invalid stash.config.ts and databaseUrl.

The mocked config.test.ts stays in place for fast schema iteration.

Test plan

  • pnpm --filter @cipherstash/cli build clean
  • pnpm --filter @cipherstash/cli test — 78 pass (was 76; +2 from the new integration test)
  • biome check clean on changed files
  • Confirm the user's reproduction (npx @cipherstash/cli db install against a config that reads databaseUrl from process.env.DATABASE_URL) succeeds with this branch

Summary by CodeRabbit

Bug Fixes

  • Fixed configuration validation to correctly process default exports from stash.config.ts. Configuration files will now be reliably validated and recognized by the CLI, ensuring your settings are properly applied.

  • Added integration tests to verify configuration file loading behavior and prevent regressions in future updates.

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 30, 2026

🦋 Changeset detected

Latest commit: 79f4a0b

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

This PR includes changesets to release 1 package
Name Type
@cipherstash/cli Minor

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
Copy link
Copy Markdown

coderabbitai Bot commented Apr 30, 2026

Caution

Review failed

Pull request was closed or merged during review

📝 Walkthrough

Walkthrough

Fixes a bug in loadStashConfig where jiti 2.x's async import() method was ignoring the constructor's interopDefault option, causing it to return the full module namespace instead of unwrapping default exports. The fix switches to per-call { default: true } options and adds an integration test using real jiti imports to prevent regressions.

Changes

Cohort / File(s) Summary
Changeset Documentation
.changeset/cli-load-stash-config-unwrap.md
Documents a minor version bump for @cipherstash/cli addressing the jiti module unwrapping issue in loadStashConfig.
Integration Tests
packages/cli/src/__tests__/config-jiti-integration.test.ts
New Vitest suite that validates loadStashConfig correctly unwraps default exports from a real temporary stash.config.ts file and properly handles validation errors when required config values are missing (e.g., databaseUrl).
Config Loading Logic
packages/cli/src/config/index.ts
Updated loadStashConfig to use jiti.import(..., { default: true }) for proper default export unwrapping; also updates loadEncryptConfig to remove constructor-level interopDefault while maintaining full namespace imports for named export detection. Includes inline comments explaining the differing jiti behaviors.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Suggested reviewers

  • auxesis

Poem

🐰 Unwrap the defaults with care and grace,
Jiti's async dances in this space,
Per-call options fix what constructors missed,
Config flows true—no namespace twist!
Tests guard the path, regression won't show,
A hopping good fix! Now onward we go! 🥕

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix(cli): unwrap default export when loading stash.config.ts' directly and clearly describes the main change: fixing default export unwrapping in the CLI's stash.config.ts loading behavior.
Linked Issues check ✅ Passed The PR fully addresses issue #374: it ensures loadStashConfig unwraps default exports via jiti.import(..., { default: true }), fixing the validation error where databaseUrl appeared undefined despite being set from process.env.
Out of Scope Changes check ✅ Passed All changes are in scope: the changeset documents the fix, the integration test validates the unwrapping and error handling, and config/index.ts implements the per-call { default: true } option as required by issue #374.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ 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 dan/fix-stash-db-install-databaseurl

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
Review rate limit: 0/1 reviews remaining, refill in 60 minutes.

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

Closes #374. `loadStashConfig` was passing `interopDefault: true` to
`createJiti(...)`, but in jiti 2.x the constructor option only applies
to the deprecated synchronous `jiti(id)` callable form — the async
`jiti.import()` ignores it and always returns the full module
namespace. With `export default defineConfig({...})` that meant Zod
was validating `{ default: { databaseUrl, client } }` and emitting

  databaseUrl: Invalid input: expected nonoptional, received undefined

even though the user's config plainly set the field.

The jiti 2.x async API exposes a per-call `{ default: true }` option
that does work. Switch to it and drop the now-misleading constructor
option from both `loadStashConfig` and `loadEncryptConfig`.
`loadEncryptConfig` wasn't symptom-bugged (it iterates `Object.values`
to find the EncryptionClient, which flattens both shapes equally) but
keeping the two call sites consistent prevents the next reader from
reasoning their way to the same wrong conclusion.

Adds `config-jiti-integration.test.ts` — drives `loadStashConfig`
against real jiti and a real temp `stash.config.ts`. The existing
`config.test.ts` mocks `jiti.import` past the bug and so couldn't
catch wrap/unwrap regressions on its own.
Copy link
Copy Markdown
Contributor

@auxesis auxesis left a comment

Choose a reason for hiding this comment

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

LGTM, thanks for fixing this @coderdan.

Just curious how you want to test the last item on the checklist in the PR description?

Copy link
Copy Markdown
Contributor

@calvinbrewer calvinbrewer left a comment

Choose a reason for hiding this comment

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

Validated by building the CLI locally and using the executable in an example application. Thanks @coderdan!

@calvinbrewer calvinbrewer merged commit a41db4c into main Apr 30, 2026
5 of 6 checks passed
@calvinbrewer calvinbrewer deleted the dan/fix-stash-db-install-databaseurl branch April 30, 2026 14:14
coderdan added a commit that referenced this pull request May 1, 2026
Onboarding users running `stash db install` (or any of the six other
DB-touching commands) without an exported `DATABASE_URL` would hit a
cryptic "Invalid stash.config.ts: databaseUrl received undefined"
error from Zod. The CLI already loads `.env.local` / `.env`* via
dotenv, but had no story for `--database-url` flags, local Supabase,
or a pasted-once one-off value.

Adds `src/config/database-url.ts` exporting `resolveDatabaseUrl()` —
called at the top of every DB-touching command before
`loadStashConfig`. It walks sources in order:

  1. `--database-url <url>` flag (explicit override)
  2. `process.env.DATABASE_URL` (shell, mise, direnv, dotenv files)
  3. `supabase status --output env` -> DB_URL, gated on `--supabase`
     or a `supabase/config.toml` in cwd
  4. Interactive `p.text` prompt (skipped under `CI=true` or
     non-TTY stdin)
  5. Hard fail with a source-naming error message

The resolved URL is mutated into `process.env.DATABASE_URL` (only
when env was unset/empty, or the source was an explicit flag) so the
existing scaffolded `process.env.DATABASE_URL!` reference in
`stash.config.ts` resolves transparently. The connection string is
never written to disk — `stash.config.ts` keeps its declarative env
reference. The source label is logged (`Using DATABASE_URL from
--database-url flag` / `from supabase status` / `from prompt`) but
the URL itself is never echoed.

Wired through `bin/stash.ts` argv (the parser already supports
`--key value`), HELP text updated. All seven DB-touching commands
(`db install`, `db push`, `db upgrade`, `db status`, `db validate`,
`db test-connection`, `schema build`) accept `--database-url` and
call the resolver before `loadStashConfig`.

After a prompt-sourced run, a `p.note` nudges the user to set
DATABASE_URL in the existing dotenv file (or `.env` if none exists)
so they don't get re-prompted next time.

Tests:
- 13 unit tests (`__tests__/database-url.test.ts`) covering each
  source, malformed flag, empty-string-env fallthrough, supabase
  fallthrough on missing binary / no-DB_URL output / no project,
  prompt-cancel, CI guard, non-TTY guard, hint-file detection.
- 2 E2E tests (`tests/e2e/database-url.e2e.test.ts`) driving the
  built binary through the pty harness: `--database-url` flag
  surfaces the source label and proceeds to (failing) connection,
  CI=true with no creds exits 1 with the CI message.

`init`/build-schema is intentionally NOT wired through the resolver:
it reads `process.env.DATABASE_URL` for provider hints pre-config and
should not trigger the prompt flow.

Note: stacks on top of #375 (the jiti default-export unwrap fix). The
resolver populates env, but `loadStashConfig` still needs the jiti
fix to read the resulting config correctly. Both PRs together
deliver the full onboarding experience.
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.

stash db install fails with "databaseUrl: received undefined" when reading from process.env

3 participants