From a994ed0ee732e4108be2d0946ae5be05904b662d Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 9 Jul 2026 19:32:43 +1000 Subject: [PATCH] fix(cli): honour the non-interactive contract in `stash init` (#600) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `stash init` advertises that "every prompt has a non-interactive escape hatch, so init never blocks waiting on a TTY (CI, agents, pipes)", but three steps called clack prompts with no interactivity gate. In a non-TTY context each prompt returned a cancel symbol, which the step treated as `CancelledError`, so `init` aborted at the first prompt without acting — and never reached the EQL step that scaffolds `stash.config.ts` (so downstream `stash status` / `eql status` dead-ended on "Could not find stash.config.ts"). Gate the three prompts on `isInteractive()` (the shared `config/tty.ts` helper), matching the existing `resolve-proxy-choice` pattern, and take a safe default in non-interactive contexts: - install-deps: install the packages by default (init is a setup command). - install-eql: install EQL by default — which also runs the config scaffold (`scaffoldConfig: 'ensure'`), closing the missing-`stash.config.ts` gap. - build-schema: keep an existing encryption client (never clobber it unasked). Interactive runs are unchanged. Adds step-level tests covering the interactive / non-interactive / already-satisfied paths for all three. --- .../init/steps/__tests__/build-schema.test.ts | 81 +++++++++++++++++++ .../init/steps/__tests__/install-deps.test.ts | 80 ++++++++++++++++++ .../init/steps/__tests__/install-eql.test.ts | 30 +++++++ .../src/commands/init/steps/build-schema.ts | 39 +++++---- .../src/commands/init/steps/install-deps.ts | 16 +++- .../src/commands/init/steps/install-eql.ts | 19 +++-- 6 files changed, 243 insertions(+), 22 deletions(-) create mode 100644 packages/cli/src/commands/init/steps/__tests__/build-schema.test.ts create mode 100644 packages/cli/src/commands/init/steps/__tests__/install-deps.test.ts diff --git a/packages/cli/src/commands/init/steps/__tests__/build-schema.test.ts b/packages/cli/src/commands/init/steps/__tests__/build-schema.test.ts new file mode 100644 index 000000000..6291a93df --- /dev/null +++ b/packages/cli/src/commands/init/steps/__tests__/build-schema.test.ts @@ -0,0 +1,81 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { InitProvider, InitState } from '../../types.js' + +const existsSyncMock = vi.hoisted(() => vi.fn(() => false)) +const writeFileSyncMock = vi.hoisted(() => vi.fn()) +vi.mock('node:fs', () => ({ + existsSync: existsSyncMock, + mkdirSync: vi.fn(), + writeFileSync: writeFileSyncMock, +})) +vi.mock('../../../../config/index.js', () => ({ + DEFAULT_CLIENT_PATH: 'src/encryption/index.ts', +})) +vi.mock('../../../../config/tty.js', () => ({ + isInteractive: vi.fn(() => true), +})) +// Raw-postgres integration: all detectors return false. +vi.mock('../../../db/detect.js', () => ({ + detectDrizzle: vi.fn(() => false), + detectPrismaNext: vi.fn(() => false), + detectSupabase: vi.fn(() => false), +})) +vi.mock('../../lib/env-keys.js', () => ({ readEnvKeyNames: vi.fn(() => []) })) +vi.mock('../../lib/write-context.js', () => ({ + writeBaselineContextFile: vi.fn(), +})) +vi.mock('../../utils.js', () => ({ + generatePlaceholderClient: vi.fn(() => '// placeholder'), +})) +vi.mock('@clack/prompts', () => ({ + select: vi.fn(async () => 'overwrite'), + isCancel: vi.fn(() => false), + log: { info: vi.fn(), success: vi.fn() }, +})) + +import * as p from '@clack/prompts' +import { isInteractive } from '../../../../config/tty.js' +import { buildSchemaStep } from '../build-schema.js' + +const baseState = { + databaseUrl: 'postgresql://localhost:5432/app', +} as unknown as InitState +const provider = { name: 'postgresql' } as unknown as InitProvider + +describe('buildSchemaStep', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(isInteractive).mockReturnValue(true) + existsSyncMock.mockReturnValue(false) + }) + + it('writes the placeholder client when none exists (no prompt)', async () => { + existsSyncMock.mockReturnValue(false) + + const result = await buildSchemaStep.run(baseState, provider) + + expect(p.select).not.toHaveBeenCalled() + expect(writeFileSyncMock).toHaveBeenCalledTimes(1) + expect(result.schemaGenerated).toBe(true) + }) + + it('prompts on an existing file when interactive', async () => { + existsSyncMock.mockReturnValue(true) + + await buildSchemaStep.run(baseState, provider) + + expect(p.select).toHaveBeenCalledTimes(1) + }) + + it('keeps an existing file without prompting when non-interactive (#600)', async () => { + existsSyncMock.mockReturnValue(true) + vi.mocked(isInteractive).mockReturnValue(false) + + const result = await buildSchemaStep.run(baseState, provider) + + // No TTY to answer; keep the user's file rather than prompt or clobber. + expect(p.select).not.toHaveBeenCalled() + expect(writeFileSyncMock).not.toHaveBeenCalled() + expect(result.schemaGenerated).toBe(false) + }) +}) diff --git a/packages/cli/src/commands/init/steps/__tests__/install-deps.test.ts b/packages/cli/src/commands/init/steps/__tests__/install-deps.test.ts new file mode 100644 index 000000000..2a1097e11 --- /dev/null +++ b/packages/cli/src/commands/init/steps/__tests__/install-deps.test.ts @@ -0,0 +1,80 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { InitProvider, InitState } from '../../types.js' + +const execSyncMock = vi.hoisted(() => vi.fn()) +vi.mock('node:child_process', () => ({ execSync: execSyncMock })) +// Collaborators from utils — control install state + commands without a real PM. +vi.mock('../../utils.js', () => ({ + isPackageInstalled: vi.fn(() => false), + combinedInstallCommands: vi.fn(() => [ + 'npm install @cipherstash/stack', + 'npm install --save-dev stash', + ]), + detectPackageManager: vi.fn(() => 'npm'), +})) +// Toggle interactivity per test (defaults to interactive in beforeEach). +vi.mock('../../../../config/tty.js', () => ({ + isInteractive: vi.fn(() => true), +})) +vi.mock('@clack/prompts', () => ({ + confirm: vi.fn(async () => true), + isCancel: vi.fn(() => false), + log: { + info: vi.fn(), + success: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + step: vi.fn(), + }, + note: vi.fn(), +})) + +import * as p from '@clack/prompts' +import { isInteractive } from '../../../../config/tty.js' +import { isPackageInstalled } from '../../utils.js' +import { installDepsStep } from '../install-deps.js' + +const baseState = {} as unknown as InitState +const provider = { name: 'postgresql' } as unknown as InitProvider + +describe('installDepsStep', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(isInteractive).mockReturnValue(true) + vi.mocked(isPackageInstalled).mockReturnValue(false) + }) + + it('prompts before installing when interactive', async () => { + // Missing at the gate, present on the post-install recheck. + let n = 0 + vi.mocked(isPackageInstalled).mockImplementation(() => ++n > 2) + + await installDepsStep.run(baseState, provider) + + expect(p.confirm).toHaveBeenCalledTimes(1) + expect(execSyncMock).toHaveBeenCalled() + }) + + it('installs without prompting when non-interactive (#600)', async () => { + vi.mocked(isInteractive).mockReturnValue(false) + let n = 0 + vi.mocked(isPackageInstalled).mockImplementation(() => ++n > 2) + + await installDepsStep.run(baseState, provider) + + // No TTY to answer the prompt; init installs by default instead of aborting. + expect(p.confirm).not.toHaveBeenCalled() + expect(execSyncMock).toHaveBeenCalled() + }) + + it('skips silently when everything is already installed (no prompt)', async () => { + vi.mocked(isPackageInstalled).mockReturnValue(true) + + const result = await installDepsStep.run(baseState, provider) + + expect(p.confirm).not.toHaveBeenCalled() + expect(execSyncMock).not.toHaveBeenCalled() + expect(result.stackInstalled).toBe(true) + expect(result.cliInstalled).toBe(true) + }) +}) 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 index 46398e768..470605cdd 100644 --- a/packages/cli/src/commands/init/steps/__tests__/install-eql.test.ts +++ b/packages/cli/src/commands/init/steps/__tests__/install-eql.test.ts @@ -6,6 +6,10 @@ import type { InitProvider, InitState } from '../../types.js' 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) })) +// Toggle interactivity per test (defaults to interactive in beforeEach). +vi.mock('../../../../config/tty.js', () => ({ + isInteractive: 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), @@ -14,6 +18,8 @@ vi.mock('@clack/prompts', () => ({ note: vi.fn(), })) +import * as p from '@clack/prompts' +import { isInteractive } from '../../../../config/tty.js' import { installCommand } from '../../../db/install.js' import { installEqlStep } from '../install-eql.js' @@ -26,6 +32,7 @@ const provider = { name: 'postgresql' } as unknown as InitProvider describe('installEqlStep', () => { beforeEach(() => { vi.clearAllMocks() + vi.mocked(isInteractive).mockReturnValue(true) }) it("requests scaffoldConfig: 'ensure' so init still creates a stash.config.ts (#581 regression)", async () => { @@ -40,4 +47,27 @@ describe('installEqlStep', () => { expect(opts.scaffoldConfig).toBe('ensure') expect(opts.databaseUrl).toBe('postgresql://localhost:5432/app') }) + + it('prompts before installing when interactive', async () => { + await installEqlStep.run(baseState, provider) + + expect(p.confirm).toHaveBeenCalledTimes(1) + expect(installCommand).toHaveBeenCalledTimes(1) + }) + + it('installs without prompting when non-interactive, and still scaffolds config (#600)', async () => { + // In a non-TTY context (CI, agents, pipes) there is no way to answer the + // prompt. init must proceed with the default (install) rather than abort, + // and still scaffold stash.config.ts via the EQL install. + vi.mocked(isInteractive).mockReturnValue(false) + + const result = await installEqlStep.run(baseState, provider) + + expect(p.confirm).not.toHaveBeenCalled() + expect(installCommand).toHaveBeenCalledTimes(1) + expect(vi.mocked(installCommand).mock.calls[0][0].scaffoldConfig).toBe( + 'ensure', + ) + expect(result.eqlInstalled).toBe(true) + }) }) diff --git a/packages/cli/src/commands/init/steps/build-schema.ts b/packages/cli/src/commands/init/steps/build-schema.ts index 210dce166..037ec1717 100644 --- a/packages/cli/src/commands/init/steps/build-schema.ts +++ b/packages/cli/src/commands/init/steps/build-schema.ts @@ -2,6 +2,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 { isInteractive } from '../../../config/tty.js' import { detectDrizzle, detectPrismaNext, @@ -92,22 +93,32 @@ export const buildSchemaStep: InitStep = { // Existing-file branch: silent overwrite is bad. Ask once. let keepExisting = false if (existsSync(resolvedPath)) { - const action = await p.select({ - message: `${clientFilePath} already exists. What would you like to do?`, - options: [ - { - value: 'keep', - label: 'Keep existing file', - hint: 'skip code generation', - }, - { value: 'overwrite', label: 'Overwrite with placeholder' }, - ], - }) + // Non-interactive (CI, agents, pipes): keep the existing file rather than + // prompt. Keeping is the safe default — never clobber the user's client + // without an explicit answer. + if (!isInteractive()) { + keepExisting = true + p.log.info( + `${clientFilePath} already exists; keeping it (non-interactive).`, + ) + } else { + const action = await p.select({ + message: `${clientFilePath} already exists. What would you like to do?`, + options: [ + { + value: 'keep', + label: 'Keep existing file', + hint: 'skip code generation', + }, + { value: 'overwrite', label: 'Overwrite with placeholder' }, + ], + }) - if (p.isCancel(action)) throw new CancelledError() + if (p.isCancel(action)) throw new CancelledError() - keepExisting = action === 'keep' - if (keepExisting) p.log.info('Keeping existing encryption client file.') + keepExisting = action === 'keep' + if (keepExisting) p.log.info('Keeping existing encryption client file.') + } } if (!keepExisting) { diff --git a/packages/cli/src/commands/init/steps/install-deps.ts b/packages/cli/src/commands/init/steps/install-deps.ts index 7a77587e2..afc6a379c 100644 --- a/packages/cli/src/commands/init/steps/install-deps.ts +++ b/packages/cli/src/commands/init/steps/install-deps.ts @@ -1,5 +1,6 @@ import { execSync } from 'node:child_process' import * as p from '@clack/prompts' +import { isInteractive } from '../../../config/tty.js' import type { InitProvider, InitState, InitStep } from '../types.js' import { CancelledError } from '../types.js' import { @@ -58,9 +59,18 @@ export const installDepsStep: InitStep = { ...devPackages.map((pkg) => `${pkg} (dev)`), ].join(', ') - const install = await p.confirm({ - message: `Install ${missingList}? (${commands.join(' && ')})`, - }) + // Non-interactive (CI, agents, pipes): no TTY to answer, so install by + // default and continue rather than abort. `stash init` is a setup command; + // installing its own dependencies is the expected non-interactive default. + if (!isInteractive()) { + p.log.info(`Installing ${missingList} (non-interactive).`) + } + const install = isInteractive() + ? await p.confirm({ + message: `Install ${missingList}? (${commands.join(' && ')})`, + initialValue: true, + }) + : true if (p.isCancel(install)) throw new CancelledError() diff --git a/packages/cli/src/commands/init/steps/install-eql.ts b/packages/cli/src/commands/init/steps/install-eql.ts index f054a4231..f647d95fd 100644 --- a/packages/cli/src/commands/init/steps/install-eql.ts +++ b/packages/cli/src/commands/init/steps/install-eql.ts @@ -1,4 +1,5 @@ import * as p from '@clack/prompts' +import { isInteractive } from '../../../config/tty.js' import { installCommand } from '../../db/install.js' import type { InitProvider, InitState, InitStep } from '../types.js' import { CancelledError } from '../types.js' @@ -44,11 +45,19 @@ export const installEqlStep: InitStep = { const supabase = integration === 'supabase' || provider.name === 'supabase' const drizzle = integration === 'drizzle' || provider.name === 'drizzle' - const proceed = await p.confirm({ - message: - 'Install the EQL extension into your database now? (required for encryption)', - initialValue: true, - }) + // Non-interactive (CI, agents, pipes): there's no TTY to answer the prompt, + // so take the default (install) and continue rather than hang or abort. This + // is what makes `stash init` honour its documented non-interactive contract. + if (!isInteractive()) { + p.log.info('Installing the EQL extension (non-interactive).') + } + const proceed = isInteractive() + ? await p.confirm({ + message: + 'Install the EQL extension into your database now? (required for encryption)', + initialValue: true, + }) + : true if (p.isCancel(proceed)) throw new CancelledError()