Skip to content

Commit 9540ec9

Browse files
authored
Merge pull request #704 from cipherstash/fix/impl-tty-detection
fix(cli): route stash impl and the two stash init prompts through the shared TTY gate
2 parents fbb3780 + e5eb696 commit 9540ec9

8 files changed

Lines changed: 527 additions & 13 deletions

File tree

.changeset/hungry-spoons-shake.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
'stash': patch
3+
---
4+
5+
Fix `stash impl` and `stash init` hanging on CI runners that allocate a TTY.
6+
7+
Four prompts decided whether to run interactively without going through the
8+
shared TTY helper, so on a CI runner with an allocated TTY they rendered a clack
9+
prompt and blocked forever on `/dev/tty` — a silent hang with no error and no
10+
timeout:
11+
12+
- `stash impl` gated on an inline `process.env.CI !== 'true'`, which only
13+
recognised the exact lowercase spelling. Runners that set `CI=1` or `CI=TRUE`
14+
blocked on the plan-summary confirmation or the agent-target picker.
15+
- `stash init`'s offer to chain into `stash plan`, and its Proxy-vs-SDK
16+
question, gated on `process.stdout.isTTY` and did not consult `CI` at all —
17+
so they hung on any CI runner with a TTY, whatever the spelling. Gating on
18+
stdout was also the wrong stream: a redirected stdin still hangs a prompt.
19+
- `stash impl --continue-without-plan` confirmed the flag with a second prompt
20+
that was not gated at all, so a CI run with no plan on disk blocked there even
21+
though the flag had already granted consent. The flag is now taken as consent
22+
in non-interactive runs and only re-confirmed interactively.
23+
24+
All four now use the shared `isInteractive()` helper (stdin is a TTY and `CI`
25+
is not set to `1`/`true` in any case), matching `stash plan`. Non-interactive
26+
runs take the path they always should have: `stash init` skips the chain offer
27+
and prints the `plan --target` hint, the Proxy-vs-SDK question defaults to
28+
SDK-only, and `stash impl` proceeds without prompting.

packages/cli/src/commands/impl/__tests__/impl.test.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,20 @@ import fs from 'node:fs'
22
import os from 'node:os'
33
import path from 'node:path'
44
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
5+
import { CliExit } from '../../../cli/exit.js'
56
import { implCommand } from '../index.js'
67
import { howToProceedStep } from '../steps/how-to-proceed.js'
78

9+
// `implCommand` reaches the plan-summary checkpoint via `p.confirm`, which
10+
// reads /dev/tty. Stub just that export (the rest of @clack/prompts stays
11+
// real) so the CI-detection cases can assert whether the prompt was reached.
12+
const confirmMock = vi.hoisted(() => vi.fn())
13+
vi.mock('@clack/prompts', async () => {
14+
const actual =
15+
await vi.importActual<typeof import('@clack/prompts')>('@clack/prompts')
16+
return { ...actual, confirm: confirmMock }
17+
})
18+
819
let originalIsTTY: boolean | undefined
920
let originalCwd: string
1021
let tmpDir: string
@@ -99,3 +110,138 @@ describe('implCommand — TTY handling', () => {
99110
expect(runSpy).not.toHaveBeenCalled()
100111
})
101112
})
113+
114+
describe('implCommand — CI detection with a TTY attached', () => {
115+
// Regression: the gate used to be an inline `process.env.CI !== 'true'`,
116+
// which only recognised the exact lowercase spelling. A CI runner that sets
117+
// CI=1 or CI=TRUE *and* allocates a TTY made `stash impl` believe it was
118+
// interactive, so it blocked on the plan-summary confirm (or the
119+
// agent-target picker) forever — a hang, not an error. The gate now goes
120+
// through `isInteractive()` in config/tty.ts, whose `isCiEnv()` accepts
121+
// 1/true in any case.
122+
beforeEach(() => {
123+
setIsTTY(true)
124+
confirmMock.mockReset()
125+
confirmMock.mockResolvedValue(true)
126+
})
127+
128+
afterEach(() => {
129+
vi.unstubAllEnvs()
130+
})
131+
132+
for (const ciValue of ['1', 'TRUE', 'true']) {
133+
it(`treats CI=${ciValue} as non-interactive even with a TTY`, async () => {
134+
vi.stubEnv('CI', ciValue)
135+
const runSpy = vi
136+
.spyOn(howToProceedStep, 'run')
137+
.mockResolvedValue({} as never)
138+
139+
await expect(implCommand({}, {})).resolves.toBeUndefined()
140+
141+
// Neither blocking prompt may be reached.
142+
expect(confirmMock).not.toHaveBeenCalled()
143+
expect(runSpy).not.toHaveBeenCalled()
144+
})
145+
}
146+
147+
it('does not re-confirm --continue-without-plan under CI when no plan exists', async () => {
148+
// The flag is the consent. Prompting again under CI blocks on /dev/tty,
149+
// and the confirm is default-no, so a resolved prompt would cancel a run
150+
// the user explicitly asked for.
151+
vi.stubEnv('CI', '1')
152+
fs.rmSync(path.join(tmpDir, '.cipherstash', 'plan.md'))
153+
const runSpy = vi
154+
.spyOn(howToProceedStep, 'run')
155+
.mockResolvedValue({} as never)
156+
157+
await expect(
158+
implCommand({ 'continue-without-plan': true }, {}),
159+
).resolves.toBeUndefined()
160+
161+
expect(confirmMock).not.toHaveBeenCalled()
162+
expect(runSpy).not.toHaveBeenCalled()
163+
})
164+
165+
it('is interactive when CI is unset and stdin is a TTY', async () => {
166+
vi.stubEnv('CI', '')
167+
const runSpy = vi
168+
.spyOn(howToProceedStep, 'run')
169+
.mockResolvedValue({} as never)
170+
171+
await implCommand({}, {})
172+
173+
expect(confirmMock).toHaveBeenCalledTimes(1)
174+
expect(runSpy).toHaveBeenCalledTimes(1)
175+
})
176+
177+
it('performs the handoff under CI=1 when --target is given', async () => {
178+
// Non-interactive means "don't prompt", not "don't run". A regression
179+
// that made the command bail early under CI would keep the not-called
180+
// cases above green — this locks the automation happy path.
181+
vi.stubEnv('CI', '1')
182+
const runSpy = vi
183+
.spyOn(howToProceedStep, 'run')
184+
.mockResolvedValue({} as never)
185+
186+
await implCommand({}, { target: 'agents-md' })
187+
188+
expect(confirmMock).not.toHaveBeenCalled()
189+
expect(runSpy).toHaveBeenCalledTimes(1)
190+
expect(runSpy.mock.calls[0][0].handoff).toBe('agents-md')
191+
})
192+
193+
it('runs the selected handoff under CI with --continue-without-plan and no plan', async () => {
194+
// The specific path the `if (isTTY)` gate at index.ts opened up: flag +
195+
// target, no plan on disk — it must run the handoff without re-confirming.
196+
vi.stubEnv('CI', '1')
197+
fs.rmSync(path.join(tmpDir, '.cipherstash', 'plan.md'))
198+
const runSpy = vi
199+
.spyOn(howToProceedStep, 'run')
200+
.mockResolvedValue({} as never)
201+
202+
await implCommand(
203+
{ 'continue-without-plan': true },
204+
{ target: 'agents-md' },
205+
)
206+
207+
expect(confirmMock).not.toHaveBeenCalled()
208+
expect(runSpy).toHaveBeenCalledTimes(1)
209+
expect(runSpy.mock.calls[0][0].handoff).toBe('agents-md')
210+
})
211+
212+
it('exits 1 with the plan hint under CI=1 when no plan and no --continue-without-plan', async () => {
213+
// CI=1 flips isTTY to false, rerouting a no-plan / no-flag run out of the
214+
// interactive `select` and into the `else if (!isTTY)` error + exit(1). It
215+
// must fail loudly, not render the "No plan found" select and block.
216+
vi.stubEnv('CI', '1')
217+
fs.rmSync(path.join(tmpDir, '.cipherstash', 'plan.md'))
218+
const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {
219+
throw new Error('process.exit')
220+
}) as never)
221+
222+
await expect(implCommand({}, {})).rejects.toThrow('process.exit')
223+
224+
expect(exitSpy).toHaveBeenCalledWith(1)
225+
expect(confirmMock).not.toHaveBeenCalled()
226+
})
227+
228+
it('still confirms --continue-without-plan interactively, and declining aborts', async () => {
229+
// The honour-the-prompt half of the `if (isTTY)` gate: with a TTY and CI
230+
// unset the default-no confirm must still fire, and declining it must abort
231+
// (CancelledError → CliExit) rather than fall through to the handoff.
232+
vi.stubEnv('CI', '')
233+
fs.rmSync(path.join(tmpDir, '.cipherstash', 'plan.md'))
234+
confirmMock.mockReset()
235+
confirmMock.mockResolvedValue(false)
236+
const runSpy = vi
237+
.spyOn(howToProceedStep, 'run')
238+
.mockResolvedValue({} as never)
239+
240+
await expect(
241+
implCommand({ 'continue-without-plan': true }, {}),
242+
).rejects.toBeInstanceOf(CliExit)
243+
244+
expect(confirmMock).toHaveBeenCalledTimes(1)
245+
expect(runSpy).not.toHaveBeenCalled()
246+
})
247+
})

packages/cli/src/commands/impl/index.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { existsSync, readFileSync } from 'node:fs'
22
import { resolve } from 'node:path'
33
import * as p from '@clack/prompts'
44
import { CliExit } from '../../cli/exit.js'
5+
import { isInteractive as isInteractiveTty } from '../../config/tty.js'
56
import { type AgentEnvironment, detectAgents } from '../init/detect-agents.js'
67
import {
78
effectiveStep,
@@ -166,11 +167,13 @@ export async function implCommand(
166167
const planPath = resolve(cwd, PLAN_REL_PATH)
167168
const planExists = existsSync(planPath)
168169
const continueWithoutPlan = flags['continue-without-plan'] === true
169-
// Interactive only when stdin is a real TTY and we're not in CI — the
170-
// same gate the encrypt commands use. `process.stdout.isTTY` alone is
171-
// wrong: a redirected stdin still hangs the agent-target picker (clack
172-
// `select` reads from /dev/tty).
173-
const isTTY = Boolean(process.stdin.isTTY) && process.env.CI !== 'true'
170+
// Interactive only when stdin is a real TTY and we're not in CI — via the
171+
// shared `isInteractive()` (config/tty.ts) so this gate stays identical to
172+
// every other prompt gate (its `isCiEnv()` treats `CI=1`/`CI=TRUE` as CI too,
173+
// which a bare `CI !== 'true'` inline would miss). `process.stdout.isTTY`
174+
// alone is wrong: a redirected stdin still hangs the agent-target picker
175+
// (clack `select` reads from /dev/tty).
176+
const isTTY = isInteractiveTty()
174177

175178
let planStep: PlanStep | undefined
176179

@@ -227,7 +230,13 @@ export async function implCommand(
227230
} else {
228231
// No plan on disk. Branch on flag / TTY / interactive.
229232
if (continueWithoutPlan) {
230-
await confirmContinueWithoutPlan()
233+
// The flag *is* the consent. Re-prompting for it non-interactively
234+
// is the same hang this gate exists to close (clack `confirm` reads
235+
// /dev/tty), and `confirmContinueWithoutPlan` is default-no, so a
236+
// prompt that did resolve would cancel a run the user asked for.
237+
if (isTTY) {
238+
await confirmContinueWithoutPlan()
239+
}
231240
} else if (!isTTY) {
232241
p.log.error(
233242
`No plan at \`${PLAN_REL_PATH}\`. Run \`${cli} plan\` first, or pass --continue-without-plan to skip planning.`,

packages/cli/src/commands/init/__tests__/init-command.test.ts

Lines changed: 104 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as p from '@clack/prompts'
2-
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
33
import { CliExit } from '../../../cli/exit.js'
44
import { messages } from '../../../messages.js'
55
import type { InitState } from '../types.js'
@@ -174,3 +174,106 @@ describe('initCommand — honest summary', () => {
174174
)
175175
})
176176
})
177+
178+
describe('initCommand — CI detection on the `stash plan` chain offer', () => {
179+
// Regression: this gate was `process.stdout.isTTY`, which consulted CI not
180+
// at all. A CI runner with an allocated TTY reached the confirm and blocked
181+
// on /dev/tty forever — a hang, not an error. It also asked about the wrong
182+
// stream: a redirected stdin still hangs the prompt. Now `isInteractive()`
183+
// (stdin + isCiEnv, which accepts 1/true in any case).
184+
const originalStdinIsTTY = process.stdin.isTTY
185+
const originalStdoutIsTTY = process.stdout.isTTY
186+
187+
// Force BOTH streams. A CI runner that allocates a TTY has stdin *and*
188+
// stdout as TTYs — that is the configuration this gate used to hang in, and
189+
// setting stdin alone would let the old `process.stdout.isTTY` gate pass
190+
// these tests for the wrong reason (vitest's own stdout is not a TTY).
191+
beforeEach(() => {
192+
Object.defineProperty(process.stdin, 'isTTY', {
193+
value: true,
194+
configurable: true,
195+
})
196+
Object.defineProperty(process.stdout, 'isTTY', {
197+
value: true,
198+
configurable: true,
199+
})
200+
})
201+
202+
afterEach(() => {
203+
Object.defineProperty(process.stdin, 'isTTY', {
204+
value: originalStdinIsTTY,
205+
configurable: true,
206+
})
207+
Object.defineProperty(process.stdout, 'isTTY', {
208+
value: originalStdoutIsTTY,
209+
configurable: true,
210+
})
211+
vi.unstubAllEnvs()
212+
})
213+
214+
for (const ciValue of ['1', 'TRUE', 'true']) {
215+
it(`skips the chain offer under CI=${ciValue} even with a TTY`, async () => {
216+
vi.stubEnv('CI', ciValue)
217+
218+
await expect(initCommand({}, {})).resolves.toBeUndefined()
219+
220+
// Non-interactive must skip the offer, not fail: init still completes,
221+
// and steers the user at `plan --target` instead of blocking.
222+
expect(vi.mocked(p.confirm)).not.toHaveBeenCalled()
223+
expect(vi.mocked(p.outro)).toHaveBeenCalledWith(
224+
expect.stringContaining('--target'),
225+
)
226+
})
227+
}
228+
229+
it('offers the chain when CI is unset and stdin is a TTY', async () => {
230+
vi.stubEnv('CI', '')
231+
vi.mocked(p.confirm).mockResolvedValueOnce(false)
232+
233+
await expect(initCommand({}, {})).resolves.toBeUndefined()
234+
235+
expect(vi.mocked(p.confirm)).toHaveBeenCalledTimes(1)
236+
})
237+
238+
it('skips the chain offer when stdin is redirected even if stdout is a TTY', async () => {
239+
// The gate keys off stdin, not stdout: `stash init < /dev/null` from a
240+
// terminal (stdin piped, stdout still a TTY) must not reach the confirm.
241+
// Reinstating `process.stdout.isTTY` would keep the CI loop above green,
242+
// so only this stdin/stdout split pins the correct stream.
243+
vi.stubEnv('CI', '')
244+
Object.defineProperty(process.stdin, 'isTTY', {
245+
value: false,
246+
configurable: true,
247+
})
248+
Object.defineProperty(process.stdout, 'isTTY', {
249+
value: true,
250+
configurable: true,
251+
})
252+
253+
await expect(initCommand({}, {})).resolves.toBeUndefined()
254+
255+
expect(vi.mocked(p.confirm)).not.toHaveBeenCalled()
256+
expect(vi.mocked(p.outro)).toHaveBeenCalledWith(
257+
expect.stringContaining('--target'),
258+
)
259+
})
260+
261+
it('chains into `stash plan` when the offer is accepted', async () => {
262+
// The accept arm (outro + planCommand() + early return) is what the gate
263+
// change made reachable; the only other confirm test resolves `false`.
264+
vi.stubEnv('CI', '')
265+
vi.mocked(p.confirm).mockResolvedValueOnce(true)
266+
const { planCommand } = await import('../../plan/index.js')
267+
268+
await expect(initCommand({}, {})).resolves.toBeUndefined()
269+
270+
expect(vi.mocked(planCommand)).toHaveBeenCalledTimes(1)
271+
expect(vi.mocked(p.outro)).toHaveBeenCalledWith(
272+
expect.stringContaining('handing off to `stash plan`'),
273+
)
274+
// The accept path returns early — it must not also print the --target hint.
275+
expect(vi.mocked(p.outro)).not.toHaveBeenCalledWith(
276+
expect.stringContaining('--target'),
277+
)
278+
})
279+
})

packages/cli/src/commands/init/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import * as p from '@clack/prompts'
22
import { CliExit } from '../../cli/exit.js'
3+
import { isInteractive } from '../../config/tty.js'
34
import { messages } from '../../messages.js'
45
import { HANDOFF_CHOICES } from '../impl/steps/how-to-proceed.js'
56
import { planCommand } from '../plan/index.js'
@@ -165,7 +166,13 @@ export async function initCommand(
165166
// multi-command flow. Drafting a plan is fast (~1–3 min of agent
166167
// thinking) and produces a reviewable artifact — `stash impl` is the
167168
// separate, slower verb that actually mutates code.
168-
if (process.stdout.isTTY) {
169+
//
170+
// Gated on the shared `isInteractive()` (config/tty.ts), the same helper
171+
// every other prompt uses. `process.stdout.isTTY` was wrong on both
172+
// counts: it ignored CI entirely (a runner with an allocated TTY blocked
173+
// here forever, since clack `confirm` reads /dev/tty), and it asked about
174+
// the wrong stream — a redirected stdin still hangs the prompt.
175+
if (isInteractive()) {
169176
const proceed = await p.confirm({
170177
message: `Continue to \`${cli} plan\` now to draft your encryption plan?`,
171178
initialValue: true,

0 commit comments

Comments
 (0)