@@ -2,9 +2,20 @@ import fs from 'node:fs'
22import os from 'node:os'
33import path from 'node:path'
44import { afterEach , beforeEach , describe , expect , it , vi } from 'vitest'
5+ import { CliExit } from '../../../cli/exit.js'
56import { implCommand } from '../index.js'
67import { 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+
819let originalIsTTY : boolean | undefined
920let originalCwd : string
1021let 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+ } )
0 commit comments