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
Original file line number Diff line number Diff line change
@@ -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)
})
})
Original file line number Diff line number Diff line change
@@ -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)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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'

Expand All @@ -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 () => {
Expand All @@ -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)
})
})
39 changes: 25 additions & 14 deletions packages/cli/src/commands/init/steps/build-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down
16 changes: 13 additions & 3 deletions packages/cli/src/commands/init/steps/install-deps.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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()

Expand Down
19 changes: 14 additions & 5 deletions packages/cli/src/commands/init/steps/install-eql.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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()

Expand Down
Loading