Skip to content

Commit a994ed0

Browse files
committed
fix(cli): honour the non-interactive contract in stash init (#600)
`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.
1 parent a91c0db commit a994ed0

6 files changed

Lines changed: 243 additions & 22 deletions

File tree

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
import type { InitProvider, InitState } from '../../types.js'
3+
4+
const existsSyncMock = vi.hoisted(() => vi.fn(() => false))
5+
const writeFileSyncMock = vi.hoisted(() => vi.fn())
6+
vi.mock('node:fs', () => ({
7+
existsSync: existsSyncMock,
8+
mkdirSync: vi.fn(),
9+
writeFileSync: writeFileSyncMock,
10+
}))
11+
vi.mock('../../../../config/index.js', () => ({
12+
DEFAULT_CLIENT_PATH: 'src/encryption/index.ts',
13+
}))
14+
vi.mock('../../../../config/tty.js', () => ({
15+
isInteractive: vi.fn(() => true),
16+
}))
17+
// Raw-postgres integration: all detectors return false.
18+
vi.mock('../../../db/detect.js', () => ({
19+
detectDrizzle: vi.fn(() => false),
20+
detectPrismaNext: vi.fn(() => false),
21+
detectSupabase: vi.fn(() => false),
22+
}))
23+
vi.mock('../../lib/env-keys.js', () => ({ readEnvKeyNames: vi.fn(() => []) }))
24+
vi.mock('../../lib/write-context.js', () => ({
25+
writeBaselineContextFile: vi.fn(),
26+
}))
27+
vi.mock('../../utils.js', () => ({
28+
generatePlaceholderClient: vi.fn(() => '// placeholder'),
29+
}))
30+
vi.mock('@clack/prompts', () => ({
31+
select: vi.fn(async () => 'overwrite'),
32+
isCancel: vi.fn(() => false),
33+
log: { info: vi.fn(), success: vi.fn() },
34+
}))
35+
36+
import * as p from '@clack/prompts'
37+
import { isInteractive } from '../../../../config/tty.js'
38+
import { buildSchemaStep } from '../build-schema.js'
39+
40+
const baseState = {
41+
databaseUrl: 'postgresql://localhost:5432/app',
42+
} as unknown as InitState
43+
const provider = { name: 'postgresql' } as unknown as InitProvider
44+
45+
describe('buildSchemaStep', () => {
46+
beforeEach(() => {
47+
vi.clearAllMocks()
48+
vi.mocked(isInteractive).mockReturnValue(true)
49+
existsSyncMock.mockReturnValue(false)
50+
})
51+
52+
it('writes the placeholder client when none exists (no prompt)', async () => {
53+
existsSyncMock.mockReturnValue(false)
54+
55+
const result = await buildSchemaStep.run(baseState, provider)
56+
57+
expect(p.select).not.toHaveBeenCalled()
58+
expect(writeFileSyncMock).toHaveBeenCalledTimes(1)
59+
expect(result.schemaGenerated).toBe(true)
60+
})
61+
62+
it('prompts on an existing file when interactive', async () => {
63+
existsSyncMock.mockReturnValue(true)
64+
65+
await buildSchemaStep.run(baseState, provider)
66+
67+
expect(p.select).toHaveBeenCalledTimes(1)
68+
})
69+
70+
it('keeps an existing file without prompting when non-interactive (#600)', async () => {
71+
existsSyncMock.mockReturnValue(true)
72+
vi.mocked(isInteractive).mockReturnValue(false)
73+
74+
const result = await buildSchemaStep.run(baseState, provider)
75+
76+
// No TTY to answer; keep the user's file rather than prompt or clobber.
77+
expect(p.select).not.toHaveBeenCalled()
78+
expect(writeFileSyncMock).not.toHaveBeenCalled()
79+
expect(result.schemaGenerated).toBe(false)
80+
})
81+
})
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
import type { InitProvider, InitState } from '../../types.js'
3+
4+
const execSyncMock = vi.hoisted(() => vi.fn())
5+
vi.mock('node:child_process', () => ({ execSync: execSyncMock }))
6+
// Collaborators from utils — control install state + commands without a real PM.
7+
vi.mock('../../utils.js', () => ({
8+
isPackageInstalled: vi.fn(() => false),
9+
combinedInstallCommands: vi.fn(() => [
10+
'npm install @cipherstash/stack',
11+
'npm install --save-dev stash',
12+
]),
13+
detectPackageManager: vi.fn(() => 'npm'),
14+
}))
15+
// Toggle interactivity per test (defaults to interactive in beforeEach).
16+
vi.mock('../../../../config/tty.js', () => ({
17+
isInteractive: vi.fn(() => true),
18+
}))
19+
vi.mock('@clack/prompts', () => ({
20+
confirm: vi.fn(async () => true),
21+
isCancel: vi.fn(() => false),
22+
log: {
23+
info: vi.fn(),
24+
success: vi.fn(),
25+
warn: vi.fn(),
26+
error: vi.fn(),
27+
step: vi.fn(),
28+
},
29+
note: vi.fn(),
30+
}))
31+
32+
import * as p from '@clack/prompts'
33+
import { isInteractive } from '../../../../config/tty.js'
34+
import { isPackageInstalled } from '../../utils.js'
35+
import { installDepsStep } from '../install-deps.js'
36+
37+
const baseState = {} as unknown as InitState
38+
const provider = { name: 'postgresql' } as unknown as InitProvider
39+
40+
describe('installDepsStep', () => {
41+
beforeEach(() => {
42+
vi.clearAllMocks()
43+
vi.mocked(isInteractive).mockReturnValue(true)
44+
vi.mocked(isPackageInstalled).mockReturnValue(false)
45+
})
46+
47+
it('prompts before installing when interactive', async () => {
48+
// Missing at the gate, present on the post-install recheck.
49+
let n = 0
50+
vi.mocked(isPackageInstalled).mockImplementation(() => ++n > 2)
51+
52+
await installDepsStep.run(baseState, provider)
53+
54+
expect(p.confirm).toHaveBeenCalledTimes(1)
55+
expect(execSyncMock).toHaveBeenCalled()
56+
})
57+
58+
it('installs without prompting when non-interactive (#600)', async () => {
59+
vi.mocked(isInteractive).mockReturnValue(false)
60+
let n = 0
61+
vi.mocked(isPackageInstalled).mockImplementation(() => ++n > 2)
62+
63+
await installDepsStep.run(baseState, provider)
64+
65+
// No TTY to answer the prompt; init installs by default instead of aborting.
66+
expect(p.confirm).not.toHaveBeenCalled()
67+
expect(execSyncMock).toHaveBeenCalled()
68+
})
69+
70+
it('skips silently when everything is already installed (no prompt)', async () => {
71+
vi.mocked(isPackageInstalled).mockReturnValue(true)
72+
73+
const result = await installDepsStep.run(baseState, provider)
74+
75+
expect(p.confirm).not.toHaveBeenCalled()
76+
expect(execSyncMock).not.toHaveBeenCalled()
77+
expect(result.stackInstalled).toBe(true)
78+
expect(result.cliInstalled).toBe(true)
79+
})
80+
})

packages/cli/src/commands/init/steps/__tests__/install-eql.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ import type { InitProvider, InitState } from '../../types.js'
66
vi.mock('../../../db/install.js', () => ({ installCommand: vi.fn() }))
77
// `stash` must appear installed so the precondition guard doesn't short-circuit.
88
vi.mock('../../utils.js', () => ({ isPackageInstalled: vi.fn(() => true) }))
9+
// Toggle interactivity per test (defaults to interactive in beforeEach).
10+
vi.mock('../../../../config/tty.js', () => ({
11+
isInteractive: vi.fn(() => true),
12+
}))
913
// Auto-approve the "install EQL now?" prompt; no-op the rest of clack.
1014
vi.mock('@clack/prompts', () => ({
1115
confirm: vi.fn(async () => true),
@@ -14,6 +18,8 @@ vi.mock('@clack/prompts', () => ({
1418
note: vi.fn(),
1519
}))
1620

21+
import * as p from '@clack/prompts'
22+
import { isInteractive } from '../../../../config/tty.js'
1723
import { installCommand } from '../../../db/install.js'
1824
import { installEqlStep } from '../install-eql.js'
1925

@@ -26,6 +32,7 @@ const provider = { name: 'postgresql' } as unknown as InitProvider
2632
describe('installEqlStep', () => {
2733
beforeEach(() => {
2834
vi.clearAllMocks()
35+
vi.mocked(isInteractive).mockReturnValue(true)
2936
})
3037

3138
it("requests scaffoldConfig: 'ensure' so init still creates a stash.config.ts (#581 regression)", async () => {
@@ -40,4 +47,27 @@ describe('installEqlStep', () => {
4047
expect(opts.scaffoldConfig).toBe('ensure')
4148
expect(opts.databaseUrl).toBe('postgresql://localhost:5432/app')
4249
})
50+
51+
it('prompts before installing when interactive', async () => {
52+
await installEqlStep.run(baseState, provider)
53+
54+
expect(p.confirm).toHaveBeenCalledTimes(1)
55+
expect(installCommand).toHaveBeenCalledTimes(1)
56+
})
57+
58+
it('installs without prompting when non-interactive, and still scaffolds config (#600)', async () => {
59+
// In a non-TTY context (CI, agents, pipes) there is no way to answer the
60+
// prompt. init must proceed with the default (install) rather than abort,
61+
// and still scaffold stash.config.ts via the EQL install.
62+
vi.mocked(isInteractive).mockReturnValue(false)
63+
64+
const result = await installEqlStep.run(baseState, provider)
65+
66+
expect(p.confirm).not.toHaveBeenCalled()
67+
expect(installCommand).toHaveBeenCalledTimes(1)
68+
expect(vi.mocked(installCommand).mock.calls[0][0].scaffoldConfig).toBe(
69+
'ensure',
70+
)
71+
expect(result.eqlInstalled).toBe(true)
72+
})
4373
})

packages/cli/src/commands/init/steps/build-schema.ts

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
22
import { dirname, resolve } from 'node:path'
33
import * as p from '@clack/prompts'
44
import { DEFAULT_CLIENT_PATH } from '../../../config/index.js'
5+
import { isInteractive } from '../../../config/tty.js'
56
import {
67
detectDrizzle,
78
detectPrismaNext,
@@ -92,22 +93,32 @@ export const buildSchemaStep: InitStep = {
9293
// Existing-file branch: silent overwrite is bad. Ask once.
9394
let keepExisting = false
9495
if (existsSync(resolvedPath)) {
95-
const action = await p.select({
96-
message: `${clientFilePath} already exists. What would you like to do?`,
97-
options: [
98-
{
99-
value: 'keep',
100-
label: 'Keep existing file',
101-
hint: 'skip code generation',
102-
},
103-
{ value: 'overwrite', label: 'Overwrite with placeholder' },
104-
],
105-
})
96+
// Non-interactive (CI, agents, pipes): keep the existing file rather than
97+
// prompt. Keeping is the safe default — never clobber the user's client
98+
// without an explicit answer.
99+
if (!isInteractive()) {
100+
keepExisting = true
101+
p.log.info(
102+
`${clientFilePath} already exists; keeping it (non-interactive).`,
103+
)
104+
} else {
105+
const action = await p.select({
106+
message: `${clientFilePath} already exists. What would you like to do?`,
107+
options: [
108+
{
109+
value: 'keep',
110+
label: 'Keep existing file',
111+
hint: 'skip code generation',
112+
},
113+
{ value: 'overwrite', label: 'Overwrite with placeholder' },
114+
],
115+
})
106116

107-
if (p.isCancel(action)) throw new CancelledError()
117+
if (p.isCancel(action)) throw new CancelledError()
108118

109-
keepExisting = action === 'keep'
110-
if (keepExisting) p.log.info('Keeping existing encryption client file.')
119+
keepExisting = action === 'keep'
120+
if (keepExisting) p.log.info('Keeping existing encryption client file.')
121+
}
111122
}
112123

113124
if (!keepExisting) {

packages/cli/src/commands/init/steps/install-deps.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { execSync } from 'node:child_process'
22
import * as p from '@clack/prompts'
3+
import { isInteractive } from '../../../config/tty.js'
34
import type { InitProvider, InitState, InitStep } from '../types.js'
45
import { CancelledError } from '../types.js'
56
import {
@@ -58,9 +59,18 @@ export const installDepsStep: InitStep = {
5859
...devPackages.map((pkg) => `${pkg} (dev)`),
5960
].join(', ')
6061

61-
const install = await p.confirm({
62-
message: `Install ${missingList}? (${commands.join(' && ')})`,
63-
})
62+
// Non-interactive (CI, agents, pipes): no TTY to answer, so install by
63+
// default and continue rather than abort. `stash init` is a setup command;
64+
// installing its own dependencies is the expected non-interactive default.
65+
if (!isInteractive()) {
66+
p.log.info(`Installing ${missingList} (non-interactive).`)
67+
}
68+
const install = isInteractive()
69+
? await p.confirm({
70+
message: `Install ${missingList}? (${commands.join(' && ')})`,
71+
initialValue: true,
72+
})
73+
: true
6474

6575
if (p.isCancel(install)) throw new CancelledError()
6676

packages/cli/src/commands/init/steps/install-eql.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as p from '@clack/prompts'
2+
import { isInteractive } from '../../../config/tty.js'
23
import { installCommand } from '../../db/install.js'
34
import type { InitProvider, InitState, InitStep } from '../types.js'
45
import { CancelledError } from '../types.js'
@@ -44,11 +45,19 @@ export const installEqlStep: InitStep = {
4445
const supabase = integration === 'supabase' || provider.name === 'supabase'
4546
const drizzle = integration === 'drizzle' || provider.name === 'drizzle'
4647

47-
const proceed = await p.confirm({
48-
message:
49-
'Install the EQL extension into your database now? (required for encryption)',
50-
initialValue: true,
51-
})
48+
// Non-interactive (CI, agents, pipes): there's no TTY to answer the prompt,
49+
// so take the default (install) and continue rather than hang or abort. This
50+
// is what makes `stash init` honour its documented non-interactive contract.
51+
if (!isInteractive()) {
52+
p.log.info('Installing the EQL extension (non-interactive).')
53+
}
54+
const proceed = isInteractive()
55+
? await p.confirm({
56+
message:
57+
'Install the EQL extension into your database now? (required for encryption)',
58+
initialValue: true,
59+
})
60+
: true
5261

5362
if (p.isCancel(proceed)) throw new CancelledError()
5463

0 commit comments

Comments
 (0)