Skip to content

Commit e3fe3cd

Browse files
committed
fix(cli): unwrap default export when loading stash.config.ts
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.
1 parent 3d12510 commit e3fe3cd

2 files changed

Lines changed: 109 additions & 7 deletions

File tree

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import fs from 'node:fs'
2+
import os from 'node:os'
3+
import path from 'node:path'
4+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
/**
7+
* Integration test for `loadStashConfig` against the *real* jiti runtime.
8+
*
9+
* The companion file `config.test.ts` mocks the `jiti` module entirely,
10+
* which is fast but can't catch wrapper/unwrap regressions in how the
11+
* default export is returned. This file deliberately does NOT mock jiti —
12+
* it writes a real `stash.config.ts` into a temp dir and asserts that
13+
* `loadStashConfig` returns the inner config rather than the module
14+
* namespace. Regression net for #374: in jiti 2.x the constructor's
15+
* `interopDefault: true` does not apply to `.import()`, so the per-call
16+
* `{ default: true }` option is required.
17+
*/
18+
19+
describe('loadStashConfig — real jiti', () => {
20+
let tmpDir: string
21+
let originalCwd: () => string
22+
let originalEnv: string | undefined
23+
24+
beforeEach(() => {
25+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'stash-config-real-jiti-'))
26+
originalCwd = process.cwd
27+
originalEnv = process.env.STASH_TEST_DATABASE_URL
28+
})
29+
30+
afterEach(() => {
31+
process.cwd = originalCwd
32+
if (originalEnv === undefined) {
33+
// biome-ignore lint/performance/noDelete: process.env.X = undefined coerces to the string "undefined" in Node, not an unset.
34+
delete process.env.STASH_TEST_DATABASE_URL
35+
} else {
36+
process.env.STASH_TEST_DATABASE_URL = originalEnv
37+
}
38+
39+
if (tmpDir && fs.existsSync(tmpDir)) {
40+
fs.rmSync(tmpDir, { recursive: true, force: true })
41+
}
42+
})
43+
44+
it('unwraps `export default {...}` to the inner config (#374 regression)', async () => {
45+
// Test-namespaced env var, not `DATABASE_URL`, to avoid clobbering a
46+
// value a developer may have set in their shell or `.env`. The bug is
47+
// about jiti's default-export wrapping, not env-var resolution — the
48+
// `process.env.X` reference inside the config is just an arbitrary
49+
// expression demonstrating that the file body actually evaluated.
50+
process.env.STASH_TEST_DATABASE_URL =
51+
'postgresql://postgres:postgres@127.0.0.1:54322/postgres'
52+
fs.writeFileSync(
53+
path.join(tmpDir, 'stash.config.ts'),
54+
`export default {
55+
databaseUrl: process.env.STASH_TEST_DATABASE_URL,
56+
client: './src/encryption/index.ts',
57+
}`,
58+
)
59+
process.cwd = () => tmpDir
60+
61+
const { loadStashConfig } = await import('@/config/index.ts')
62+
const config = await loadStashConfig()
63+
64+
expect(config).toEqual({
65+
databaseUrl: 'postgresql://postgres:postgres@127.0.0.1:54322/postgres',
66+
client: './src/encryption/index.ts',
67+
})
68+
})
69+
70+
it('reports a useful error when databaseUrl is genuinely missing', async () => {
71+
// biome-ignore lint/performance/noDelete: see afterEach above; need an actual unset.
72+
delete process.env.STASH_TEST_DATABASE_URL
73+
fs.writeFileSync(
74+
path.join(tmpDir, 'stash.config.ts'),
75+
`export default {
76+
databaseUrl: process.env.STASH_TEST_DATABASE_URL,
77+
}`,
78+
)
79+
process.cwd = () => tmpDir
80+
81+
const errSpy = vi
82+
.spyOn(console, 'error')
83+
.mockImplementation(() => undefined)
84+
vi.spyOn(process, 'exit').mockImplementation(() => {
85+
throw new Error('process.exit')
86+
})
87+
88+
const { loadStashConfig } = await import('@/config/index.ts')
89+
await expect(loadStashConfig()).rejects.toThrow('process.exit')
90+
91+
const allCalls = errSpy.mock.calls.flat().join('\n')
92+
expect(allCalls).toContain('Invalid stash.config.ts')
93+
expect(allCalls).toContain('databaseUrl')
94+
})
95+
})

packages/cli/src/config/index.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -97,13 +97,19 @@ Create a ${CONFIG_FILENAME} file in your project root:
9797
}
9898

9999
const { createJiti } = await import('jiti')
100-
const jiti = createJiti(configPath, {
101-
interopDefault: true,
102-
})
100+
const jiti = createJiti(configPath)
103101

104102
let rawConfig: unknown
105103
try {
106-
rawConfig = await jiti.import(configPath)
104+
// The per-call `{ default: true }` option is the jiti 2.x way to ask
105+
// for the default export to be unwrapped. The `interopDefault`
106+
// *constructor* option only applies to the deprecated synchronous
107+
// `jiti(id)` callable form — `jiti.import()` silently ignores it and
108+
// returns the full module namespace (`{ default: { ... } }`). That
109+
// wrapper would then fail Zod validation with a misleading
110+
// "databaseUrl: received undefined" even when the user's config sets
111+
// it (#374).
112+
rawConfig = await jiti.import(configPath, { default: true })
107113
} catch (error) {
108114
console.error(`Error: Failed to load ${CONFIG_FILENAME} at ${configPath}\n`)
109115
console.error(error)
@@ -148,12 +154,13 @@ export async function loadEncryptConfig(
148154
}
149155

150156
const { createJiti } = await import('jiti')
151-
const jiti = createJiti(resolvedPath, {
152-
interopDefault: true,
153-
})
157+
const jiti = createJiti(resolvedPath)
154158

155159
let moduleExports: Record<string, unknown>
156160
try {
161+
// No `{ default: true }` here — we want the full module namespace so
162+
// `Object.values` can find an EncryptionClient regardless of whether
163+
// the user re-exports it as `default` or as a named binding.
157164
moduleExports = (await jiti.import(resolvedPath)) as Record<string, unknown>
158165
} catch (error) {
159166
console.error(

0 commit comments

Comments
 (0)