Skip to content
Merged
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
25 changes: 25 additions & 0 deletions .changeset/stash-cli-config-scaffold-dx.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
"stash": patch
---

Fix two config-scaffold dead-ends in the CLI (#578, #579).

- **Missing config is now actionable.** When a command that needs a
`stash.config.ts` can't find one, the error recommends `stash init` /
`stash eql install` (runner-aware) instead of only telling you to hand-write
the file.
- **`stash eql install` no longer requires a `stash.config.ts`.** It only needs
a database URL, so it now resolves one directly (`--database-url` → env →
`supabase status` → prompt) instead of scaffolding a config and loading it.
That means a standalone `npx stash eql install --database-url ...` works in a
bare project with **zero dependencies** — no more crash with a raw
`Cannot find module 'stash'` from the config's `import`. A plain
`stash eql install` still honours an existing config (later workflow commands
rely on it) and offers to scaffold one otherwise. An explicit `--database-url`
is a one-shot install: it resolves that URL directly and leaves the project
untouched — no config or client is scaffolded, and an existing config is
bypassed so the flag can't be silently overridden by a hand-edited literal
`databaseUrl` (including one in a parent directory).
- As a safety net, `loadStashConfig` translates a missing-module load failure
(a project that *has* a config but lacks the CLI packages) into the same
actionable guidance for every command, instead of a jiti/Node stack trace.
117 changes: 117 additions & 0 deletions packages/cli/src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,76 @@ describe('loadStashConfig', () => {
await expect(loadStashConfig()).rejects.toThrow('process.exit')
})

it('points the user at `init` / `eql install` when config is missing (#578)', async () => {
process.cwd = () => tmpDir
vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit')
})
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})

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

const output = errorSpy.mock.calls.map((c) => c.join(' ')).join('\n')
expect(output).toContain('stash init')
expect(output).toContain('stash eql install')
})

it.each([
['stash', `Cannot find module 'stash'`],
['@cipherstash/stack', `Cannot find package '@cipherstash/stack'`],
])(
'translates a missing `%s` module into actionable guidance (#579)',
async (pkg, message) => {
fs.writeFileSync(
path.join(tmpDir, 'stash.config.ts'),
`import 'stash'\nexport default {}`,
)
process.cwd = () => tmpDir
vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit')
})
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})

const moduleErr = Object.assign(new Error(message), {
code: 'MODULE_NOT_FOUND',
})
const { createJiti } = await import('jiti')
vi.mocked(createJiti).mockReturnValue({
import: vi.fn().mockRejectedValue(moduleErr),
} as never)

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

const output = errorSpy.mock.calls.map((c) => c.join(' ')).join('\n')
expect(output).toContain(`\`${pkg}\` is not installed`)
expect(output).toContain('stash init')
// The raw jiti stack trace must NOT be forwarded to the user.
expect(output).not.toContain('Failed to load')
},
)

it('still surfaces the raw error for unrelated config load failures', async () => {
fs.writeFileSync(path.join(tmpDir, 'stash.config.ts'), 'export default {}')
process.cwd = () => tmpDir
vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit')
})
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})

const { createJiti } = await import('jiti')
vi.mocked(createJiti).mockReturnValue({
import: vi.fn().mockRejectedValue(new SyntaxError('Unexpected token')),
} as never)

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

const output = errorSpy.mock.calls.map((c) => c.join(' ')).join('\n')
expect(output).toContain('Failed to load')
})

it('validates required fields', async () => {
// Write a config file that exists but exports an empty object
fs.writeFileSync(path.join(tmpDir, 'stash.config.ts'), 'export default {}')
Expand Down Expand Up @@ -81,3 +151,50 @@ describe('loadStashConfig', () => {
})
})
})

describe('loadEncryptConfig', () => {
let tmpDir: string
let originalCwd: () => string

beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'stash-encrypt-config-test-'))
originalCwd = process.cwd
})

afterEach(() => {
process.cwd = originalCwd
vi.restoreAllMocks()
if (tmpDir && fs.existsSync(tmpDir)) {
fs.rmSync(tmpDir, { recursive: true, force: true })
}
})

it('translates a missing @cipherstash/stack in the client file into guidance (#3)', async () => {
// The client (not the config) is what imports @cipherstash/stack, incl.
// subpaths. This path used to dump a raw jiti stack trace.
fs.writeFileSync(path.join(tmpDir, 'client.ts'), '// encryption client')
process.cwd = () => tmpDir
vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit')
})
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})

const moduleErr = Object.assign(
new Error("Cannot find module '@cipherstash/stack/schema'"),
{ code: 'MODULE_NOT_FOUND' },
)
const { createJiti } = await import('jiti')
vi.mocked(createJiti).mockReturnValue({
import: vi.fn().mockRejectedValue(moduleErr),
} as never)

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

const output = errorSpy.mock.calls.map((c) => c.join(' ')).join('\n')
expect(output).toContain('`@cipherstash/stack` is not installed')
expect(output).toContain('stash init')
// Not the raw stack-trace path.
expect(output).not.toContain('Failed to load encrypt client')
})
})
3 changes: 3 additions & 0 deletions packages/cli/src/bin/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,9 @@ async function runInstall(
migrationsDir: values['migrations-dir'],
eqlVersion: values['eql-version'],
databaseUrl: values['database-url'],
// An explicit `--database-url` is a one-shot install against that DB — leave
// the project untouched. Otherwise offer to scaffold a config for later.
scaffoldConfig: values['database-url'] !== undefined ? 'skip' : 'offer',
})
}

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/cli/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ const DATABASE_URL_FLAG: Flag = {
name: '--database-url',
value: '<url>',
description:
'Override DATABASE_URL for this run only — never written to disk.',
'Database URL for this run only — never written to disk. Highest precedence in the resolution order: --database-url flag → DATABASE_URL env → supabase status → interactive prompt. A stash.config.ts is not a separate tier (its default databaseUrl re-runs this same chain); a hand-set literal databaseUrl in the config bypasses the resolver and wins over all of these.',
env: 'DATABASE_URL',
}

Expand Down
69 changes: 69 additions & 0 deletions packages/cli/src/commands/db/__tests__/config-scaffold.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
CONFIG_FILENAME,
DEFAULT_CLIENT_PATH,
offerStashConfig,
} from '../config-scaffold.js'

describe('offerStashConfig (optional config scaffold)', () => {
let tmpDir: string

beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'stash-config-scaffold-'))
})

afterEach(() => {
vi.restoreAllMocks()
if (tmpDir && fs.existsSync(tmpDir)) {
fs.rmSync(tmpDir, { recursive: true, force: true })
}
})

it('ensure mode creates the config with the default client path (init path)', async () => {
// `ensure` is how `stash init` requests a config unconditionally; it must
// write one without prompting.
const clientPath = await offerStashConfig({ ensure: true, cwd: tmpDir })

expect(clientPath).toBe(DEFAULT_CLIENT_PATH)
const written = fs.readFileSync(path.join(tmpDir, CONFIG_FILENAME), 'utf-8')
expect(written).toContain("from 'stash'")
expect(written).toContain(`client: '${DEFAULT_CLIENT_PATH}'`)
})

it('ensure mode points the config at a detected client file when one exists', async () => {
fs.mkdirSync(path.join(tmpDir, 'src'))
fs.writeFileSync(path.join(tmpDir, 'src', 'encryption.ts'), '// client')

const clientPath = await offerStashConfig({ ensure: true, cwd: tmpDir })

expect(clientPath).toBe('./src/encryption.ts')
expect(
fs.readFileSync(path.join(tmpDir, CONFIG_FILENAME), 'utf-8'),
).toContain(`client: './src/encryption.ts'`)
})

it('offer mode writes nothing and returns null in a non-interactive context (#2, #4)', async () => {
// Under vitest process.stdin.isTTY is undefined → non-interactive. Without
// `ensure`, offer must NOT silently drop a config into the project, and the
// null return is what makes the caller skip the client scaffold too.
const clientPath = await offerStashConfig({ cwd: tmpDir })

expect(clientPath).toBeNull()
expect(fs.existsSync(path.join(tmpDir, CONFIG_FILENAME))).toBe(false)
})

it('never overwrites an existing config (returns null)', async () => {
const configPath = path.join(tmpDir, CONFIG_FILENAME)
fs.writeFileSync(configPath, '// hand-written, do not touch')

const clientPath = await offerStashConfig({ cwd: tmpDir })

expect(clientPath).toBeNull()
expect(fs.readFileSync(configPath, 'utf-8')).toBe(
'// hand-written, do not touch',
)
})
})
87 changes: 65 additions & 22 deletions packages/cli/src/commands/db/config-scaffold.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,21 @@
import { existsSync, writeFileSync } from 'node:fs'
import { resolve } from 'node:path'
import * as p from '@clack/prompts'
import { DEFAULT_CLIENT_PATH } from '../../config/index.js'
import { isInteractive } from '../../config/tty.js'
import { detectPackageManager, runnerCommand } from '../init/utils.js'

export const CONFIG_FILENAME = 'stash.config.ts'

// Re-exported so scaffold consumers (and their tests) have a single import site.
export { DEFAULT_CLIENT_PATH }

/**
* Common locations where an encryption client file might live. Checked in
* order of priority during auto-detection.
* order of priority during auto-detection — the default path leads.
*/
const COMMON_CLIENT_PATHS = [
'./src/encryption/index.ts',
DEFAULT_CLIENT_PATH,
'./src/encryption.ts',
'./encryption/index.ts',
'./encryption.ts',
Expand Down Expand Up @@ -51,9 +57,9 @@ export async function resolveClientPath(

const clientPath = await p.text({
message: 'Where is your encryption client file?',
placeholder: './src/encryption/index.ts',
defaultValue: './src/encryption/index.ts',
initialValue: detected ?? './src/encryption/index.ts',
placeholder: DEFAULT_CLIENT_PATH,
defaultValue: DEFAULT_CLIENT_PATH,
initialValue: detected ?? DEFAULT_CLIENT_PATH,
validate(value) {
if (!value || value.trim().length === 0) {
return 'Client file path is required.'
Expand Down Expand Up @@ -82,29 +88,66 @@ export default defineConfig({
`
}

/** Write the config with the given client path and report it. */
function writeStashConfig(configPath: string, clientPath: string): string {
writeFileSync(configPath, generateConfig(clientPath), 'utf-8')
p.log.success(`Created ${CONFIG_FILENAME}`)
return clientPath
}

/**
* Create a `stash.config.ts` at the project root if one doesn't already exist.
* Returns `true` if a config is present (either pre-existing or freshly
* written), `false` if the user cancelled the prompt.
* Create a `stash.config.ts` for the rest of the workflow (`db push` /
* `schema build` / `encrypt *` load the encryption client through it).
* `eql install` itself doesn't need one — it resolves the database URL
* directly — so this is a setup convenience, never a blocker.
*
* - `opts.ensure` (used by `stash init`, where the user has already committed
* to setup) creates the config without a yes/no prompt.
* - Otherwise it *offers* to create it interactively. A non-interactive run
* (CI / agents / pipes) can't prompt, so it does nothing rather than silently
* writing files that reference packages a bare project may not have installed.
*
* Invoked by `eql install` when no `stash.config.ts` exists, so users don't
* need to run a separate `setup` step before installing EQL.
* Returns the encryption-client path the config points at when a config was
* written, or `null` when nothing was created (declined, non-interactive, or an
* existing config) — so the caller skips the client scaffold too.
*
* Should be called only when no config exists yet (the caller loads an existing
* one instead); it never overwrites a present `stash.config.ts`.
*/
export async function ensureStashConfig(
cwd: string = process.cwd(),
): Promise<boolean> {
export async function offerStashConfig(
opts: { ensure?: boolean; cwd?: string } = {},
): Promise<string | null> {
const cwd = opts.cwd ?? process.cwd()
const configPath = resolve(cwd, CONFIG_FILENAME)
if (existsSync(configPath)) return true
if (existsSync(configPath)) return null

p.log.info(`No ${CONFIG_FILENAME} found — let's create one.`)
// `ensure` (init) creates the config without asking — the user already
// committed to setup by running `stash init`.
if (opts.ensure) {
return writeStashConfig(
configPath,
detectClientPath(cwd) ?? DEFAULT_CLIENT_PATH,
)
}

const clientPath = await resolveClientPath(cwd)
if (!clientPath) {
p.cancel('Setup cancelled.')
return false
// 'offer' mode. A non-interactive run can't prompt; don't write into the
// project unasked (that could drop files importing uninstalled packages) —
// the missing-config guidance points the user at `stash init` later.
if (!isInteractive()) return null

const create = await p.confirm({
message: `Create a ${CONFIG_FILENAME}? (needed later for db push / schema build / encrypt)`,
initialValue: true,
})
if (p.isCancel(create) || !create) {
p.log.info(
`Skipped ${CONFIG_FILENAME}. Create it later with \`${runnerCommand(detectPackageManager(), 'stash init')}\`.`,
)
return null
}

writeFileSync(configPath, generateConfig(clientPath), 'utf-8')
p.log.success(`Created ${CONFIG_FILENAME}`)
return true
const clientPath = await resolveClientPath(cwd)
if (!clientPath) return null

return writeStashConfig(configPath, clientPath)
}
Loading
Loading