diff --git a/.changeset/stash-cli-config-scaffold-dx.md b/.changeset/stash-cli-config-scaffold-dx.md new file mode 100644 index 00000000..ade7f56d --- /dev/null +++ b/.changeset/stash-cli-config-scaffold-dx.md @@ -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. diff --git a/packages/cli/src/__tests__/config.test.ts b/packages/cli/src/__tests__/config.test.ts index 5048519b..d3094584 100644 --- a/packages/cli/src/__tests__/config.test.ts +++ b/packages/cli/src/__tests__/config.test.ts @@ -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 {}') @@ -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') + }) +}) diff --git a/packages/cli/src/bin/main.ts b/packages/cli/src/bin/main.ts index 3bebf438..3a0ed517 100644 --- a/packages/cli/src/bin/main.ts +++ b/packages/cli/src/bin/main.ts @@ -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', }) } diff --git a/packages/cli/src/cli/registry.ts b/packages/cli/src/cli/registry.ts index 9f4176b2..eb7d3fa9 100644 --- a/packages/cli/src/cli/registry.ts +++ b/packages/cli/src/cli/registry.ts @@ -54,7 +54,7 @@ const DATABASE_URL_FLAG: Flag = { name: '--database-url', value: '', 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', } diff --git a/packages/cli/src/commands/db/__tests__/config-scaffold.test.ts b/packages/cli/src/commands/db/__tests__/config-scaffold.test.ts new file mode 100644 index 00000000..c81b72d9 --- /dev/null +++ b/packages/cli/src/commands/db/__tests__/config-scaffold.test.ts @@ -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', + ) + }) +}) diff --git a/packages/cli/src/commands/db/config-scaffold.ts b/packages/cli/src/commands/db/config-scaffold.ts index 35947f32..8bec16da 100644 --- a/packages/cli/src/commands/db/config-scaffold.ts +++ b/packages/cli/src/commands/db/config-scaffold.ts @@ -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', @@ -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.' @@ -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 { +export async function offerStashConfig( + opts: { ensure?: boolean; cwd?: string } = {}, +): Promise { + 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) } diff --git a/packages/cli/src/commands/db/install.ts b/packages/cli/src/commands/db/install.ts index b4c97249..9cb013b9 100644 --- a/packages/cli/src/commands/db/install.ts +++ b/packages/cli/src/commands/db/install.ts @@ -9,14 +9,16 @@ import { import * as p from '@clack/prompts' import pg from 'pg' import { detectPackageManager, runnerCommand } from '@/commands/init/utils.js' -import { loadStashConfig } from '@/config/index.js' +import { resolveDatabaseUrl } from '@/config/database-url.js' +import { findConfigFile, loadStashConfig } from '@/config/index.js' +import { isInteractive } from '@/config/tty.js' import { downloadEqlSql, EQLInstaller, loadBundledEqlSql, } from '@/installer/index.js' import { ensureEncryptionClient } from './client-scaffold.js' -import { ensureStashConfig } from './config-scaffold.js' +import { offerStashConfig } from './config-scaffold.js' import { detectDrizzle, detectSupabase, @@ -65,6 +67,15 @@ export interface InstallOptions { * never persisted. See `src/config/database-url.ts`. */ databaseUrl?: string + /** + * How to handle a missing `stash.config.ts` — the caller owns this intent + * rather than it being inferred from whether a URL was supplied: + * - `'ensure'` — create it without asking (`stash init`, where the user has + * already committed to setting the project up). + * - `'offer'` — offer to create it (plain `stash eql install`). Default. + * - `'skip'` — never scaffold (a one-shot `eql install --database-url ...`). + */ + scaffoldConfig?: 'ensure' | 'offer' | 'skip' /** * EQL generation to install: `'2'` (default, composite `eql_v2_encrypted`) * or `'3'` (native `eql_v3.*` domain schema). v3 currently supports the @@ -76,6 +87,84 @@ export interface InstallOptions { /** Resolved install mode for the Supabase non-Drizzle branch. */ export type SupabaseInstallMode = 'migration' | 'direct' +/** + * Resolve the database URL + encryption-client path for the install. + * + * A pre-existing `stash.config.ts` is authoritative — later workflow commands + * (db push / schema build / encrypt) load the client through it — so we load + * it. Without one, `eql install` doesn't need a config: resolve the URL + * directly (flag → env → supabase → prompt). Decoupling install from the config + * is what lets `npx stash eql install --database-url ...` run in a bare project + * without the `stash` / `@cipherstash/stack` dependencies the config would + * otherwise import (#579). + * + * Whether a missing config gets scaffolded is the caller's explicit intent + * ({@link InstallOptions.scaffoldConfig}), not inferred from whether a URL was + * supplied — `stash init` passes a resolved URL but still wants a config, while + * a one-shot `--database-url` run wants the project left untouched. When no + * config is created, `clientPath` comes back `null` so the caller skips the + * client scaffold too. + * + * A one-shot run (`mode === 'skip'`, set when `--database-url` is passed alone) + * bypasses config loading entirely: it must leave the project untouched, so it + * neither honours nor scaffolds a config or client. This also means the flag + * always wins — loading a config here could pick up a parent-directory config + * with a hand-edited literal `databaseUrl` that silently overrides the flag and + * installs EQL against the wrong database. + */ +async function resolveInstallContext( + options: InstallOptions, + s: ReturnType, +): Promise<{ databaseUrl: string; clientPath: string | null }> { + const mode = options.scaffoldConfig ?? 'offer' + + // A one-shot `--database-url` install leaves the project untouched: don't load + // an existing config (see doc comment re: parent-dir literal override) and + // don't scaffold. Resolve the URL directly and return a null clientPath. + const configPath = mode === 'skip' ? null : findConfigFile(process.cwd()) + if (configPath) { + s.start('Loading stash.config.ts...') + // Pass the path we already located so loadStashConfig doesn't re-walk the + // filesystem to find it. + const config = await loadStashConfig( + { + databaseUrlFlag: options.databaseUrl, + supabase: options.supabase, + }, + configPath, + ) + s.stop('Configuration loaded.') + + // A config with a hand-edited literal `databaseUrl` bypasses the resolver, + // so a `--database-url` flag would be silently ignored. Surface it rather + // than installing against a different database than the user asked for — + // especially since `findConfigFile` walks up into parent directories. + if ( + options.databaseUrl !== undefined && + config.databaseUrl !== options.databaseUrl.trim() + ) { + p.log.warn( + `Ignoring --database-url: ${configPath} sets an explicit databaseUrl that takes precedence. Installing against the config's database.`, + ) + } + return { databaseUrl: config.databaseUrl, clientPath: config.client } + } + + const databaseUrl = await resolveDatabaseUrl({ + databaseUrlFlag: options.databaseUrl, + supabase: options.supabase, + }) + + // A dry run must not write scaffold files, so it never enters the scaffold + // path (nor does an explicit `skip`). + if (mode === 'skip' || options.dryRun) { + return { databaseUrl, clientPath: null } + } + + const clientPath = await offerStashConfig({ ensure: mode === 'ensure' }) + return { databaseUrl, clientPath } +} + export async function installCommand(options: InstallOptions) { p.intro(runnerCommand(detectPackageManager(), 'stash eql install')) @@ -90,31 +179,30 @@ export async function installCommand(options: InstallOptions) { process.exit(1) } - // Scaffold stash.config.ts if missing. `eql install` is the single command - // that gets a project from zero to installed EQL — no separate setup step - // (CIP-2986). - const configReady = await ensureStashConfig() - if (!configReady) { - process.exit(0) - } - const s = p.spinner() - s.start('Loading stash.config.ts...') - const config = await loadStashConfig({ - databaseUrlFlag: options.databaseUrl, - supabase: options.supabase, - }) - s.stop('Configuration loaded.') + // `eql install` only needs a database URL to install EQL. It does NOT require + // a stash.config.ts: an existing config is authoritative (later workflow + // commands rely on it), but without one we resolve the URL directly — so a + // standalone `npx stash eql install --database-url ...` works with zero + // project dependencies. A one-shot `--database-url` run leaves the project + // untouched; otherwise we offer to scaffold a config for the rest of the + // workflow (CIP-2986 / #579). + const { databaseUrl, clientPath } = await resolveInstallContext(options, s) // Safety net: if the user ran `eql install` without first running `init`, - // scaffold the encryption client file so config.client points somewhere - // real. No-op when the file already exists. - ensureEncryptionClient(config.client, process.cwd(), config.databaseUrl) + // scaffold the encryption client file so clientPath points somewhere real. + // No-op when the file already exists. Skipped for a one-shot `--database-url` + // install (clientPath === null) or a dry run, which leave the project + // untouched — an existing config still yields a clientPath, so guard on dryRun + // too. + if (clientPath && !options.dryRun) { + ensureEncryptionClient(clientPath, process.cwd(), databaseUrl) + } // Auto-detect provider hints when the user didn't explicitly pass flags. // CIP-2985. - const resolved = resolveProviderOptions(options, config.databaseUrl) + const resolved = resolveProviderOptions(options, databaseUrl) const eqlVersion: 2 | 3 = options.eqlVersion === '3' ? 3 : 2 @@ -183,7 +271,7 @@ export async function installCommand(options: InstallOptions) { } const installer = new EQLInstaller({ - databaseUrl: config.databaseUrl, + databaseUrl, }) s.start('Checking database permissions...') @@ -249,7 +337,7 @@ export async function installCommand(options: InstallOptions) { } s.start('Installing cs_migrations tracking schema...') - const migrationsDb = new pg.Client({ connectionString: config.databaseUrl }) + const migrationsDb = new pg.Client({ connectionString: databaseUrl }) try { await migrationsDb.connect() await installMigrationsSchema(migrationsDb) @@ -617,12 +705,12 @@ async function resolveSupabaseInstallMode( options: InstallOptions, projectInfo: SupabaseProjectInfo, ): Promise { - const isTTY = Boolean(process.stdin.isTTY) && process.env.CI !== 'true' - const decided = chooseSupabaseInstallMode(options, projectInfo, isTTY) + const interactive = isInteractive() + const decided = chooseSupabaseInstallMode(options, projectInfo, interactive) if (decided !== null) { if ( - !isTTY && + !interactive && options.migration === undefined && options.direct === undefined ) { diff --git a/packages/cli/src/commands/init/steps/__tests__/install-eql.test.ts b/packages/cli/src/commands/init/steps/__tests__/install-eql.test.ts new file mode 100644 index 00000000..46398e76 --- /dev/null +++ b/packages/cli/src/commands/init/steps/__tests__/install-eql.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { InitProvider, InitState } from '../../types.js' + +// installCommand is the unit under test's collaborator — mock it so we assert +// what init asks for without touching a database. +vi.mock('../../../db/install.js', () => ({ installCommand: vi.fn() })) +// `stash` must appear installed so the precondition guard doesn't short-circuit. +vi.mock('../../utils.js', () => ({ isPackageInstalled: vi.fn(() => true) })) +// Auto-approve the "install EQL now?" prompt; no-op the rest of clack. +vi.mock('@clack/prompts', () => ({ + confirm: vi.fn(async () => true), + isCancel: vi.fn(() => false), + log: { info: vi.fn(), error: vi.fn(), success: vi.fn(), warn: vi.fn() }, + note: vi.fn(), +})) + +import { installCommand } from '../../../db/install.js' +import { installEqlStep } from '../install-eql.js' + +const baseState = { + integration: 'postgresql', + databaseUrl: 'postgresql://localhost:5432/app', +} as unknown as InitState +const provider = { name: 'postgresql' } as unknown as InitProvider + +describe('installEqlStep', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("requests scaffoldConfig: 'ensure' so init still creates a stash.config.ts (#581 regression)", async () => { + // Regression guard: init passes a resolved databaseUrl only to avoid + // re-prompting. If installCommand treated a present databaseUrl as a + // one-shot `--database-url` run, init would finish with no config and every + // downstream command would dead-end on 'Could not find stash.config.ts'. + await installEqlStep.run(baseState, provider) + + expect(installCommand).toHaveBeenCalledTimes(1) + const opts = vi.mocked(installCommand).mock.calls[0][0] + expect(opts.scaffoldConfig).toBe('ensure') + expect(opts.databaseUrl).toBe('postgresql://localhost:5432/app') + }) +}) diff --git a/packages/cli/src/commands/init/steps/build-schema.ts b/packages/cli/src/commands/init/steps/build-schema.ts index c6212a30..210dce16 100644 --- a/packages/cli/src/commands/init/steps/build-schema.ts +++ b/packages/cli/src/commands/init/steps/build-schema.ts @@ -1,6 +1,7 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import * as p from '@clack/prompts' +import { DEFAULT_CLIENT_PATH } from '../../../config/index.js' import { detectDrizzle, detectPrismaNext, @@ -17,8 +18,6 @@ import type { import { CancelledError } from '../types.js' import { generatePlaceholderClient } from '../utils.js' -const DEFAULT_CLIENT_PATH = './src/encryption/index.ts' - /** * Pick the integration template by reading the same signals `eql install` * uses — Drizzle config / dependency for `drizzle`, Supabase host in diff --git a/packages/cli/src/commands/init/steps/install-eql.ts b/packages/cli/src/commands/init/steps/install-eql.ts index 7dd48fb5..f054a423 100644 --- a/packages/cli/src/commands/init/steps/install-eql.ts +++ b/packages/cli/src/commands/init/steps/install-eql.ts @@ -62,11 +62,10 @@ export const installEqlStep: InitStep = { } // installCommand scaffolds stash.config.ts (which `import`s from `stash`) - // and immediately loads it via jiti. If `stash` isn't actually loadable - // from the project, that load throws `Cannot find module 'stash'` from - // deep inside jiti — confusing and fatal mid-flow. Detect the precondition - // and bail with a clear message instead. install-deps is what installs - // the package, so a "no" there leaves us here. + // for the rest of the workflow. `stash` must be installed or the config the + // user relies on next (db push / schema build / encrypt) can't load. Detect + // the precondition and bail with a clear message instead. install-deps is + // what installs the package, so a "no" there leaves us here. if (!isPackageInstalled('stash')) { p.log.error( '`stash` is not installed in this project. The previous step (install-deps) was skipped or failed. Re-run `stash init` and accept the dependency install when prompted, or install it manually:', @@ -83,6 +82,9 @@ export const installEqlStep: InitStep = { supabase: supabase || undefined, drizzle: drizzle || undefined, databaseUrl: state.databaseUrl, + // init passes a resolved URL to avoid re-prompting, but still wants a + // config scaffolded — this is NOT a one-shot `--database-url` run. + scaffoldConfig: 'ensure', }) } catch { // Don't echo the underlying error — Postgres client errors routinely diff --git a/packages/cli/src/config/__tests__/missing-package.test.ts b/packages/cli/src/config/__tests__/missing-package.test.ts new file mode 100644 index 00000000..10b5eeb9 --- /dev/null +++ b/packages/cli/src/config/__tests__/missing-package.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { missingCipherStashPackage } from '../missing-package.js' + +function moduleErr(message: string, code = 'MODULE_NOT_FOUND'): Error { + return Object.assign(new Error(message), { code }) +} + +describe('missingCipherStashPackage', () => { + it('detects a missing bare `stash` (the config import)', () => { + expect( + missingCipherStashPackage(moduleErr("Cannot find module 'stash'")), + ).toBe('stash') + }) + + it('detects a missing `@cipherstash/stack` (ESM "package" wording)', () => { + expect( + missingCipherStashPackage( + moduleErr( + "Cannot find package '@cipherstash/stack'", + 'ERR_MODULE_NOT_FOUND', + ), + ), + ).toBe('@cipherstash/stack') + }) + + it('reduces a subpath specifier to its package name (#3)', () => { + // The client imports subpaths like `@cipherstash/stack/schema`; the brittle + // substring match this replaced missed those. + expect( + missingCipherStashPackage( + moduleErr("Cannot find module '@cipherstash/stack/schema'"), + ), + ).toBe('@cipherstash/stack') + }) + + it('ignores a missing third-party package', () => { + expect( + missingCipherStashPackage(moduleErr("Cannot find module 'left-pad'")), + ).toBeUndefined() + }) + + it('ignores errors that are not module-not-found', () => { + expect( + missingCipherStashPackage( + moduleErr("Cannot find module 'stash'", 'EACCES'), + ), + ).toBeUndefined() + expect(missingCipherStashPackage(new SyntaxError('boom'))).toBeUndefined() + }) + + it('ignores non-Error values', () => { + expect(missingCipherStashPackage(undefined)).toBeUndefined() + expect(missingCipherStashPackage('boom')).toBeUndefined() + }) +}) diff --git a/packages/cli/src/config/database-url.ts b/packages/cli/src/config/database-url.ts index 94d66da6..eade7999 100644 --- a/packages/cli/src/config/database-url.ts +++ b/packages/cli/src/config/database-url.ts @@ -1,6 +1,7 @@ /** - * Layered DATABASE_URL resolution. Called from inside the user's - * `stash.config.ts` via: + * Layered DATABASE_URL resolution. Called both directly by CLI commands (e.g. + * `eql install` when there's no config) and from inside the user's + * `stash.config.ts`: * * import { defineConfig, resolveDatabaseUrl } from 'stash' * export default defineConfig({ @@ -8,29 +9,14 @@ * }) * * The CLI's `loadStashConfig` wraps the jiti-import in - * `withResolverContext({ databaseUrlFlag, supabase })` (an - * `AsyncLocalStorage` scope) before evaluating the config file. Any - * `resolveDatabaseUrl()` call inside the file then sees those options - * via `als.getStore()` and walks: + * `withResolverContext({ databaseUrlFlag, supabase })` (an `AsyncLocalStorage` + * scope) before evaluating the config file, so a `resolveDatabaseUrl()` call + * inside the config picks up the CLI's flags via `als.getStore()`. See the + * {@link resolveDatabaseUrl} doc comment for the authoritative priority order. * - * 1. `--database-url ` flag (explicit override). - * 2. `process.env.DATABASE_URL` (shell, mise, direnv, dotenv files - * loaded by `bin/stash.ts`). - * 3. `supabase status --output env` → `DB_URL`, when `--supabase` is - * set OR a `supabase/config.toml` is detected. - * 4. Interactive `p.text` prompt (skipped under `CI=true` or non-TTY - * stdin). - * 5. Hard-fail with a source-naming error. - * - * Returns the resolved URL string. The CLI never mutates - * `process.env.DATABASE_URL` — the URL is only carried in the value - * `defineConfig` returns. The connection string is never persisted to - * disk; `stash.config.ts` references this function, not a literal. - * - * Concurrency: the ALS context is per-async-flow, so multiple - * concurrent `loadStashConfig` calls (e.g. parallel test cases or a - * programmatic batch invocation) each get isolated options without - * stepping on each other. + * Concurrency: the ALS context is per-async-flow, so multiple concurrent + * `loadStashConfig` calls (e.g. parallel test cases or a programmatic batch + * invocation) each get isolated options without stepping on each other. */ import { AsyncLocalStorage } from 'node:async_hooks' @@ -41,7 +27,7 @@ import * as p from '@clack/prompts' import { detectSupabaseProject } from '../commands/db/detect.js' import { detectPackageManager, runnerCommand } from '../commands/init/utils.js' import { messages } from '../messages.js' -import { isCiEnv } from './tty.js' +import { isCiEnv, isInteractive } from './tty.js' export interface ResolveDatabaseUrlOptions { /** Value of `--database-url` if the user passed one. */ @@ -165,12 +151,29 @@ async function promptForUrl(cwd: string): Promise { } /** - * Walk the resolution chain and return a usable DATABASE_URL. Reads - * options from the surrounding `withResolverContext` scope (set by the - * CLI before evaluating the config file); any explicit `opts` passed - * here override the scoped values. + * Resolve the database connection URL from the first available source, in + * strict priority order (first hit wins): + * + * 1. `--database-url ` flag (or `databaseUrlFlag` in the resolver + * context) — explicit, highest precedence. + * 2. `process.env.DATABASE_URL` — shell, mise/direnv, or a `.env*` file + * loaded at startup by `bin/stash.ts`. + * 3. `supabase status --output env` → `DB_URL` — only when `--supabase` is + * set OR a `supabase/config.toml` is detected. + * 4. Interactive prompt — skipped under CI / non-TTY stdin. + * 5. Hard-fail with a source-naming error. + * + * `stash.config.ts` is NOT a separate tier. The scaffolded config's field is + * `databaseUrl: await resolveDatabaseUrl()`, so loading a config just re-runs + * this same chain — and because `loadStashConfig` threads `--database-url` in + * via `withResolverContext`, the flag still wins even when a config is present. + * The one exception is a hand-edited config that assigns a literal + * `databaseUrl` string: that bypasses this resolver entirely, so the literal + * takes precedence over the flag and env. * - * Exits 1 when no source resolves a URL. + * The resolved URL is returned only — never written to `process.env` or + * persisted to disk. `opts` override the ALS context + * (`{ ...als.getStore(), ...opts }`). */ export async function resolveDatabaseUrl( opts: ResolveDatabaseUrlOptions = {}, @@ -205,12 +208,12 @@ export async function resolveDatabaseUrl( } } - // 4. Interactive prompt — skipped in CI / non-TTY. `isCiEnv` accepts the - // common CI-truthy spellings (`true`, `1`, case-insensitive) since not every - // CI provider sets `CI=true` exactly; shared with the region resolver. + // 4. Interactive prompt — skipped in CI / non-TTY. `isInteractive` (shared + // with the config scaffolder and region resolver) accepts the common + // CI-truthy spellings (`true`, `1`, case-insensitive) since not every CI + // provider sets `CI=true` exactly. const isCi = isCiEnv() - const isInteractive = Boolean(process.stdin.isTTY) && !isCi - if (isInteractive) { + if (isInteractive()) { const fromPrompt = await promptForUrl(cwd) if (fromPrompt) { p.log.info(messages.db.urlResolvedFromPrompt) diff --git a/packages/cli/src/config/index.ts b/packages/cli/src/config/index.ts index a4d732b5..7103d7ba 100644 --- a/packages/cli/src/config/index.ts +++ b/packages/cli/src/config/index.ts @@ -3,10 +3,15 @@ import path from 'node:path' import type { EncryptionClient } from '@cipherstash/stack/encryption' import type { EncryptConfig } from '@cipherstash/stack/schema' import { z } from 'zod' +import { detectPackageManager, runnerCommand } from '../commands/init/utils.js' import { type ResolveDatabaseUrlOptions, withResolverContext, } from './database-url.js' +import { + missingCipherStashPackage, + reportMissingCipherStashPackage, +} from './missing-package.js' export interface StashConfig { /** PostgreSQL connection string */ @@ -39,22 +44,29 @@ export function defineConfig(config: StashConfig): StashConfig { const CONFIG_FILENAME = 'stash.config.ts' -const DEFAULT_ENCRYPT_CLIENT_PATH = './src/encryption/index.ts' +/** + * Default encryption-client path — the single source of truth for the location + * a scaffolded config points at and the Zod default when `client` is omitted. + * Imported by the scaffolder and the init schema builder so they can't drift. + */ +export const DEFAULT_CLIENT_PATH = './src/encryption/index.ts' const stashConfigSchema = z.object({ databaseUrl: z .string({ required_error: 'databaseUrl is required' }) .min(1, 'databaseUrl must not be empty'), - client: z.string().default(DEFAULT_ENCRYPT_CLIENT_PATH), + client: z.string().default(DEFAULT_CLIENT_PATH), }) /** * Search for `stash.config.ts` starting from `startDir` and walking up * parent directories until the filesystem root is reached. * - * Returns the absolute path if found, or `undefined` if not. + * Returns the absolute path if found, or `undefined` if not. Exported so + * commands can branch on whether a config is present without loading it (e.g. + * `eql install` resolves the database URL directly when there's no config). */ -function findConfigFile(startDir: string): string | undefined { +export function findConfigFile(startDir: string): string | undefined { let dir = path.resolve(startDir) while (true) { @@ -88,17 +100,25 @@ function findConfigFile(startDir: string): string | undefined { * command. This is how the CLI passes flag context into config * evaluation without mutating `process.env` or relying on globals. * + * `knownConfigPath` lets a caller that already located the config (via + * {@link findConfigFile}) skip a second cwd→root filesystem walk. + * * Exits with code 1 if the config file is not found or fails validation. */ export async function loadStashConfig( resolverOptions: ResolveDatabaseUrlOptions = {}, + knownConfigPath?: string, ): Promise { - const configPath = findConfigFile(process.cwd()) + const configPath = knownConfigPath ?? findConfigFile(process.cwd()) if (!configPath) { + const stash = runnerCommand(detectPackageManager(), 'stash') console.error(`Error: Could not find ${CONFIG_FILENAME} -Create a ${CONFIG_FILENAME} file in your project root: +Run \`${stash} init\` to set up CipherStash (recommended), or +\`${stash} eql install\` to scaffold a ${CONFIG_FILENAME} and install EQL. + +To create it by hand, add ${CONFIG_FILENAME} to your project root: import { defineConfig, resolveDatabaseUrl } from 'stash' @@ -126,6 +146,11 @@ Create a ${CONFIG_FILENAME} file in your project root: jiti.import(configPath, { default: true }), ) } catch (error) { + // A missing CipherStash package (the config `import`s `stash`) is the common + // standalone-npx failure — translate jiti's raw `Cannot find module 'stash'` + // into actionable guidance instead of a stack trace (#579). + const missingPkg = missingCipherStashPackage(error) + if (missingPkg) reportMissingCipherStashPackage(missingPkg) console.error(`Error: Failed to load ${CONFIG_FILENAME} at ${configPath}\n`) console.error(error) process.exit(1) @@ -178,6 +203,11 @@ export async function loadEncryptConfig( // the user re-exports it as `default` or as a named binding. moduleExports = (await jiti.import(resolvedPath)) as Record } catch (error) { + // The client `import`s `@cipherstash/stack` (incl. subpaths). If that isn't + // installed, translate the raw jiti stack trace into the same actionable + // guidance the config load gives, rather than leaking it (#579 / review #3). + const missingPkg = missingCipherStashPackage(error) + if (missingPkg) reportMissingCipherStashPackage(missingPkg) console.error( `Error: Failed to load encrypt client file at ${resolvedPath}\n`, ) diff --git a/packages/cli/src/config/missing-package.ts b/packages/cli/src/config/missing-package.ts new file mode 100644 index 00000000..fc5665ec --- /dev/null +++ b/packages/cli/src/config/missing-package.ts @@ -0,0 +1,52 @@ +// Turn a raw jiti/Node `Cannot find module 'stash'` into actionable guidance. +// A `stash.config.ts` `import`s `stash`; the encryption client it points at +// `import`s `@cipherstash/stack` (incl. subpaths like `@cipherstash/stack/schema`). +// Both resolve only once those are project dependencies — via `stash init`, or a +// manual install — so a bare project (e.g. `npx stash eql install` before init) +// otherwise crashes with a stack trace. See #579. + +import { + combinedInstallCommands, + detectPackageManager, + runnerCommand, +} from '../commands/init/utils.js' +import { messages } from '../messages.js' +import { isModuleNotFound, moduleNotFoundSpecifier } from '../module-error.js' + +const CLI_PACKAGE = 'stash' +const STACK_PACKAGE = '@cipherstash/stack' + +/** Reduce a specifier (`@cipherstash/stack/schema`, `stash/foo`) to its package name. */ +function packageNameOf(specifier: string): string { + const parts = specifier.split('/') + return specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0] +} + +/** + * If `error` is a module-resolution failure for one of the CipherStash packages + * a config (`stash`) or its encryption client (`@cipherstash/stack`, incl. + * subpaths) imports, return that package name. Returns `undefined` for any other + * error, so the caller can surface it raw. + */ +export function missingCipherStashPackage(error: unknown): string | undefined { + if (!isModuleNotFound(error)) return undefined + const specifier = moduleNotFoundSpecifier(error) + if (!specifier) return undefined + const pkg = packageNameOf(specifier) + return pkg === CLI_PACKAGE || pkg === STACK_PACKAGE ? pkg : undefined +} + +/** + * Print actionable guidance for a missing CipherStash package and exit, instead + * of leaking jiti's raw stack trace. Shared by every command that jiti-loads the + * config or the encryption client. + */ +export function reportMissingCipherStashPackage(pkg: string): never { + const pm = detectPackageManager() + const stash = runnerCommand(pm, 'stash') + const install = combinedInstallCommands(pm, [STACK_PACKAGE], [CLI_PACKAGE]) + console.error( + `Error: ${messages.db.missingCipherStashPackage(pkg, install.join('\n '), stash)}\n`, + ) + process.exit(1) +} diff --git a/packages/cli/src/config/tty.ts b/packages/cli/src/config/tty.ts index e8c6cf44..09fd476a 100644 --- a/packages/cli/src/config/tty.ts +++ b/packages/cli/src/config/tty.ts @@ -13,3 +13,14 @@ export function isCiEnv(): boolean { const ciVar = process.env.CI?.trim() return ciVar !== undefined && /^(1|true)$/i.test(ciVar) } + +/** + * True when it's safe to show an interactive clack prompt: stdin is a TTY and + * we're not in CI. Every prompt gate (the DATABASE_URL resolver, the config + * scaffolder, the Supabase install-mode selector) must decide this the same + * way, so they all call here rather than re-deriving `isTTY && !CI` inline and + * drifting apart. + */ +export function isInteractive(): boolean { + return Boolean(process.stdin.isTTY) && !isCiEnv() +} diff --git a/packages/cli/src/messages.ts b/packages/cli/src/messages.ts index 9195e6b7..0e7e418a 100644 --- a/packages/cli/src/messages.ts +++ b/packages/cli/src/messages.ts @@ -96,5 +96,17 @@ export const messages = { /** Nudge shown after a prompt-sourced run completes. */ urlHint: (file: string) => `Set DATABASE_URL in ${file} to skip this prompt next time.`, + /** + * Shown when a `stash.config.ts` (or the encryption client it points at) + * can't load because a CipherStash package isn't installed. `installCommands` + * is the newline-joined install invocation and `stash` the runner-aware + * `stash` prefix (e.g. `npx stash`). + */ + missingCipherStashPackage: ( + pkg: string, + installCommands: string, + stash: string, + ) => + `\`${pkg}\` is not installed in this project.\n\nInstall the CipherStash packages, then re-run:\n ${installCommands}\n\nOr run \`${stash} init\` to set everything up.`, }, } as const diff --git a/packages/cli/src/module-error.ts b/packages/cli/src/module-error.ts new file mode 100644 index 00000000..4f5f06c9 --- /dev/null +++ b/packages/cli/src/module-error.ts @@ -0,0 +1,28 @@ +// Shared helpers for Node module-resolution failures. A missing dependency +// surfaces as a `MODULE_NOT_FOUND` (CJS require) or `ERR_MODULE_NOT_FOUND` (ESM +// import) whose message names the unresolved specifier. These turn that raw +// error into structured data callers translate into guidance — missing native +// binaries in `native.ts`, missing CipherStash packages in +// `config/missing-package.ts`. + +/** A Node module-resolution error. */ +export interface ModuleError extends Error { + code?: string + requireStack?: string[] +} + +/** True when `err` is a CJS `MODULE_NOT_FOUND` or ESM `ERR_MODULE_NOT_FOUND`. */ +export function isModuleNotFound(err: unknown): err is ModuleError { + if (!(err instanceof Error)) return false + const code = (err as ModuleError).code + return code === 'MODULE_NOT_FOUND' || code === 'ERR_MODULE_NOT_FOUND' +} + +/** + * The quoted specifier from a module-not-found message, or `undefined`. CJS says + * "Cannot find module 'X'"; ESM says "Cannot find package 'X'". Handles subpath + * specifiers too (e.g. `@cipherstash/stack/schema`). + */ +export function moduleNotFoundSpecifier(err: ModuleError): string | undefined { + return /Cannot find (?:module|package) '([^']+)'/.exec(err.message)?.[1] +} diff --git a/packages/cli/src/native.ts b/packages/cli/src/native.ts index 732957fa..969b3547 100644 --- a/packages/cli/src/native.ts +++ b/packages/cli/src/native.ts @@ -15,11 +15,11 @@ import { type PackageManager, runnerCommand, } from './commands/init/utils.js' - -interface ModuleError extends Error { - code?: string - requireStack?: string[] -} +import { + isModuleNotFound, + type ModuleError, + moduleNotFoundSpecifier, +} from './module-error.js' // Matches the platform-suffixed optional package, e.g. // `@cipherstash/protect-ffi-darwin-arm64` or `@cipherstash/auth-linux-x64-gnu`. @@ -37,13 +37,8 @@ export function currentTarget(): string { * to a missing top-level package or any other module error. */ export function isNativeBinaryMissing(err: unknown): err is ModuleError { - if (!(err instanceof Error)) return false - const e = err as ModuleError - // CJS require throws `MODULE_NOT_FOUND`; ESM throws `ERR_MODULE_NOT_FOUND`. - if (e.code !== 'MODULE_NOT_FOUND' && e.code !== 'ERR_MODULE_NOT_FOUND') { - return false - } - const haystack = `${e.message}\n${(e.requireStack ?? []).join('\n')}` + if (!isModuleNotFound(err)) return false + const haystack = `${err.message}\n${(err.requireStack ?? []).join('\n')}` // A platform-suffixed @cipherstash package, or a failure surfaced from the // neon loader, both mean the optional native binary wasn't installed. return ( @@ -58,9 +53,7 @@ function missingModuleName(err: ModuleError): string | undefined { // `-` guess would miss. const pkg = PLATFORM_PKG.exec(haystack)?.[0] if (pkg) return pkg - // Fall back to the quoted name. CJS says "Cannot find module 'X'"; ESM - // (ERR_MODULE_NOT_FOUND) says "Cannot find package 'X'". - return /Cannot find (?:module|package) '([^']+)'/.exec(err.message)?.[1] + return moduleNotFoundSpecifier(err) } const LOCKFILE: Record = { diff --git a/packages/cli/tests/e2e/config-scaffold.e2e.test.ts b/packages/cli/tests/e2e/config-scaffold.e2e.test.ts new file mode 100644 index 00000000..6eba9e5b --- /dev/null +++ b/packages/cli/tests/e2e/config-scaffold.e2e.test.ts @@ -0,0 +1,163 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { run } from '../helpers/run.js' + +/** + * E2E coverage for the config-scaffold DX fixes (#578, #579). These run the + * built CLI in a throwaway temp project OUTSIDE the monorepo, so `stash` / + * `@cipherstash/stack` genuinely don't resolve from `node_modules` — the exact + * condition that produced the raw `Cannot find module 'stash'` crash. + */ +describe('config-scaffold DX (missing config / missing deps)', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'stash-config-e2e-')) + fs.writeFileSync( + path.join(tmpDir, 'package.json'), + JSON.stringify({ name: 'bare', version: '1.0.0' }), + ) + }) + + afterEach(() => { + if (tmpDir && fs.existsSync(tmpDir)) { + fs.rmSync(tmpDir, { recursive: true, force: true }) + } + }) + + it('a read command with no config points at `init` / `eql install` (#578)', async () => { + // `eql status` loads the encryption client, so it genuinely needs a config. + const r = await run(['eql', 'status'], { cwd: tmpDir }) + expect(r.output).toContain('Could not find stash.config.ts') + expect(r.output).toContain('stash init') + expect(r.output).toContain('stash eql install') + }) + + it('`eql install` does not require a config — it resolves the URL directly (#579)', async () => { + // With no config and no database URL, install must NOT scaffold a config and + // then crash on `Cannot find module 'stash'`. It resolves the URL first and + // fails cleanly asking for one; no stash.config.ts is written. + // Clear DATABASE_URL explicitly so a value in the parent env can't leak in + // and resolve past the guidance we're asserting on. + const r = await run(['eql', 'install'], { + cwd: tmpDir, + env: { DATABASE_URL: '' }, + }) + + expect(r.output).toContain('Cannot resolve DATABASE_URL') + expect(r.output).toContain('--database-url') + expect(r.output).not.toContain('Cannot find module') + expect(r.output).not.toContain('MODULE_NOT_FOUND') + expect(fs.existsSync(path.join(tmpDir, 'stash.config.ts'))).toBe(false) + expect(r.exitCode).toBe(1) + }) + + it('`eql install --database-url` is one-shot — it never scaffolds project files', async () => { + // An explicit --database-url means "install EQL against this DB now"; it + // must not drop a stash.config.ts or an encryption client into the project. + // The bogus URL fails fast at connect (past the config stage), which is all + // we need to observe the no-scaffold behaviour. + const r = await run( + [ + 'eql', + 'install', + '--database-url', + 'postgres://u:p@127.0.0.1:1/db?connect_timeout=2', + ], + { cwd: tmpDir }, + ) + + expect(fs.existsSync(path.join(tmpDir, 'stash.config.ts'))).toBe(false) + expect( + fs.existsSync(path.join(tmpDir, 'src', 'encryption', 'index.ts')), + ).toBe(false) + expect(r.output).not.toContain('Created stash.config.ts') + expect(r.output).not.toContain('Cannot find module') + }) + + it('`eql install --database-url` leaves an existing config untouched — no client scaffolded', async () => { + // One-shot install with an EXISTING stash.config.ts pointing at a + // not-yet-created client. The config branch must be bypassed entirely: no + // client file is written, and the `stash` import in the config never runs + // (so no `Cannot find module` in this bare project). The bogus URL fails + // fast at connect, past the config/scaffold stage. + fs.writeFileSync( + path.join(tmpDir, 'stash.config.ts'), + "import { defineConfig, resolveDatabaseUrl } from 'stash'\n" + + "export default defineConfig({ databaseUrl: await resolveDatabaseUrl(), client: './src/encryption/index.ts' })\n", + ) + + const r = await run( + [ + 'eql', + 'install', + '--database-url', + 'postgres://u:p@127.0.0.1:1/db?connect_timeout=2', + ], + { cwd: tmpDir }, + ) + + expect( + fs.existsSync(path.join(tmpDir, 'src', 'encryption', 'index.ts')), + ).toBe(false) + expect(r.output).not.toContain('Created stash.config.ts') + expect(r.output).not.toContain('Cannot find module') + }) + + it('non-interactive `eql install` (URL from env) writes no config or client (#2, #4)', async () => { + // No --database-url flag but DATABASE_URL in the env: offer mode, but a + // non-TTY run can't prompt. It must NOT silently scaffold a config (which + // imports `stash`) or a client (which imports `@cipherstash/stack`) into a + // bare project. offerStashConfig returns null and the client scaffold is + // skipped; the bogus URL then fails fast at connect. + const r = await run(['eql', 'install'], { + cwd: tmpDir, + env: { DATABASE_URL: 'postgres://u:p@127.0.0.1:1/db?connect_timeout=2' }, + }) + + expect(fs.existsSync(path.join(tmpDir, 'stash.config.ts'))).toBe(false) + expect( + fs.existsSync(path.join(tmpDir, 'src', 'encryption', 'index.ts')), + ).toBe(false) + expect(r.output).not.toContain('Created stash.config.ts') + expect(r.output).not.toContain('Cannot find module') + }) + + it('`eql install --dry-run` writes no files, even with an existing config', async () => { + // Dry run must not mutate the project. With an existing config pointing at a + // not-yet-created client, the client scaffold used to run before the dry-run + // guard — so assert the client file is NOT written. + fs.writeFileSync( + path.join(tmpDir, 'stash.config.ts'), + "import { defineConfig, resolveDatabaseUrl } from 'stash'\n" + + "export default defineConfig({ databaseUrl: await resolveDatabaseUrl(), client: './src/encryption/index.ts' })\n", + ) + + const r = await run( + [ + 'eql', + 'install', + '--dry-run', + '--database-url', + 'postgres://u:p@127.0.0.1:1/db?connect_timeout=2', + ], + { cwd: tmpDir }, + ) + + expect(r.output).toContain('Dry run') + expect( + fs.existsSync(path.join(tmpDir, 'src', 'encryption', 'index.ts')), + ).toBe(false) + expect(r.exitCode).toBe(0) + }) + + // The `loadStashConfig` catch that translates a missing-module error into + // guidance (the path for a project that HAS a config but lacks the CLI + // packages) is covered by unit tests in src/__tests__/config.test.ts with a + // mocked jiti rejection. It can't be reproduced end-to-end here: inside the + // monorepo jiti resolves `stash` via the workspace self-reference even from a + // temp dir, so the import never fails — which is exactly why the original bug + // escaped the test suite. +})