Three commands can hang forever on a CI runner that allocates a TTY, because each reimplements the interactivity check inline instead of using config/tty.ts, and each gets a different subset wrong.
The shared helper is isInteractive() = Boolean(process.stdin.isTTY) && !isCiEnv(), where isCiEnv() matches /^(1|true)$/i against a trimmed CI.
| Site |
Was |
Failure |
commands/impl/index.ts |
process.stdin.isTTY && process.env.CI !== 'true' |
CI=1 / CI=TRUE pass → blocks on p.confirm or the agent-target picker |
commands/init/index.ts |
process.stdout.isTTY |
no CI check → blocks on the "chain into stash plan?" confirm |
commands/init/steps/resolve-proxy-choice.ts |
process.stdout.isTTY |
no CI check → blocks on the proxy-vs-SDK select |
Two distinct bugs: impl had the right stream and the wrong CI test; both init sites had the wrong stream (stdout where prompts read stdin) and no CI test — strictly more exposed, since neither caught even CI=true.
It hangs rather than errors because clack's prompts open /dev/tty directly rather than reading process.stdin, so there is nothing to fail — the prompt waits until the job hits its wall-clock limit. That is also why it survived: invisible on a dev machine, invisible on GitHub Actions (CI=true), and on the CI systems where it fires it looks like a hung runner, not a CLI defect.
Fix
PR #704 routes all three through isInteractive(). Both init sites already had correct non-interactive branches in their else — only the gate selecting them was wrong. resolve-proxy-choice defaults to SDK-only under CI (unchanged behaviour: it is the prompt's own initialValue).
Regression tests cover CI=1, CI=TRUE, CI=true and CI-unset across all three sites, forcing both stdin and stdout as TTYs (a real CI runner has both — an earlier test version that stubbed only stdin passed against the broken code for an unrelated reason).
Three commands can hang forever on a CI runner that allocates a TTY, because each reimplements the interactivity check inline instead of using
config/tty.ts, and each gets a different subset wrong.The shared helper is
isInteractive()=Boolean(process.stdin.isTTY) && !isCiEnv(), whereisCiEnv()matches/^(1|true)$/iagainst a trimmedCI.commands/impl/index.tsprocess.stdin.isTTY && process.env.CI !== 'true'CI=1/CI=TRUEpass → blocks onp.confirmor the agent-target pickercommands/init/index.tsprocess.stdout.isTTYCIcheck → blocks on the "chain intostash plan?" confirmcommands/init/steps/resolve-proxy-choice.tsprocess.stdout.isTTYCIcheck → blocks on the proxy-vs-SDK selectTwo distinct bugs:
implhad the right stream and the wrongCItest; both init sites had the wrong stream (stdoutwhere prompts readstdin) and noCItest — strictly more exposed, since neither caught evenCI=true.It hangs rather than errors because clack's prompts open
/dev/ttydirectly rather than readingprocess.stdin, so there is nothing to fail — the prompt waits until the job hits its wall-clock limit. That is also why it survived: invisible on a dev machine, invisible on GitHub Actions (CI=true), and on the CI systems where it fires it looks like a hung runner, not a CLI defect.Fix
PR #704 routes all three through
isInteractive(). Both init sites already had correct non-interactive branches in theirelse— only the gate selecting them was wrong.resolve-proxy-choicedefaults to SDK-only under CI (unchanged behaviour: it is the prompt's owninitialValue).Regression tests cover
CI=1,CI=TRUE,CI=trueand CI-unset across all three sites, forcing both stdin and stdout as TTYs (a real CI runner has both — an earlier test version that stubbed only stdin passed against the broken code for an unrelated reason).