Skip to content
Closed
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
90 changes: 90 additions & 0 deletions packages/cli/src/__tests__/config-jiti-integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

/**
* Integration test for `loadStashConfig` against the *real* jiti runtime.
*
* The companion file `config.test.ts` mocks the `jiti` module entirely,
* which is fast but can't catch wrapper/unwrap regressions in how the
* default export is returned. This file deliberately does NOT mock jiti —
* it writes a real `stash.config.ts` into a temp dir and asserts that
* `loadStashConfig` returns the inner config rather than the module
* namespace. Regression net for #374: in jiti 2.x the constructor's
* `interopDefault: true` does not apply to `.import()`, so the per-call
* `{ default: true }` option is required.
*/

describe('loadStashConfig — real jiti', () => {
let tmpDir: string
let originalCwd: () => string
let originalEnv: string | undefined

beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'stash-config-real-jiti-'))
originalCwd = process.cwd
originalEnv = process.env.STASH_TEST_DATABASE_URL
})

afterEach(() => {
process.cwd = originalCwd
if (originalEnv === undefined) {
// biome-ignore lint/performance/noDelete: process.env.X = undefined coerces to the string "undefined" in Node, not an unset.
delete process.env.STASH_TEST_DATABASE_URL
} else {
process.env.STASH_TEST_DATABASE_URL = originalEnv
}

if (tmpDir && fs.existsSync(tmpDir)) {
fs.rmSync(tmpDir, { recursive: true, force: true })
}
})

it('unwraps `export default {...}` to the inner config (#374 regression)', async () => {
process.env.STASH_TEST_DATABASE_URL =
'postgresql://postgres:postgres@127.0.0.1:54322/postgres'
fs.writeFileSync(
path.join(tmpDir, 'stash.config.ts'),
`export default {
databaseUrl: process.env.STASH_TEST_DATABASE_URL,
client: './src/encryption/index.ts',
}`,
)
process.cwd = () => tmpDir

const { loadStashConfig } = await import('@/config/index.ts')
const config = await loadStashConfig()

expect(config).toEqual({
databaseUrl: 'postgresql://postgres:postgres@127.0.0.1:54322/postgres',
client: './src/encryption/index.ts',
})
})

it('reports a useful error when databaseUrl is genuinely missing', async () => {
// biome-ignore lint/performance/noDelete: see afterEach above; need an actual unset.
delete process.env.STASH_TEST_DATABASE_URL
fs.writeFileSync(
path.join(tmpDir, 'stash.config.ts'),
`export default {
databaseUrl: process.env.STASH_TEST_DATABASE_URL,
}`,
)
process.cwd = () => tmpDir

const errSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => undefined)
vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit')
})

const { loadStashConfig } = await import('@/config/index.ts')
await expect(loadStashConfig()).rejects.toThrow('process.exit')

const allCalls = errSpy.mock.calls.flat().join('\n')
expect(allCalls).toContain('Invalid stash.config.ts')
expect(allCalls).toContain('databaseUrl')
})
})
21 changes: 14 additions & 7 deletions packages/cli/src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,19 @@ Create a ${CONFIG_FILENAME} file in your project root:
}

const { createJiti } = await import('jiti')
const jiti = createJiti(configPath, {
interopDefault: true,
})
const jiti = createJiti(configPath)

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

const { createJiti } = await import('jiti')
const jiti = createJiti(resolvedPath, {
interopDefault: true,
})
const jiti = createJiti(resolvedPath)

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