Skip to content

fix(cli): route stash impl and the two stash init prompts through the shared TTY gate#704

Merged
tobyhede merged 4 commits into
mainfrom
fix/impl-tty-detection
Jul 21, 2026
Merged

fix(cli): route stash impl and the two stash init prompts through the shared TTY gate#704
tobyhede merged 4 commits into
mainfrom
fix/impl-tty-detection

Conversation

@tobyhede

@tobyhede tobyhede commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Closes #727

Closes #727

Three commands could hang forever on a CI runner that allocates a TTY. Each reimplemented the interactivity check inline instead of using config/tty.ts, and each got a different subset right.

isInteractive() is Boolean(process.stdin.isTTY) && !isCiEnv(), where isCiEnv() matches /^(1|true)$/i against a trimmed CI.

The three sites

Site Was Failure
commands/impl/index.ts process.stdin.isTTY && process.env.CI !== 'true' CI=1 / CI=TRUE pass the check → blocks on p.confirm or the agent-target picker
commands/init/index.ts process.stdout.isTTY no CI check at all → 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 (unless --proxy/--no-proxy short-circuits it)

plan/index.ts already used the shared helper, and carries a comment explaining precisely why the inline form is wrong — impl sat a few files away doing exactly that.

Two distinct bugs are being fixed. impl had the right stream and the wrong CI test. Both init sites had the wrong stream 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's nothing to fail — the prompt just waits until the job hits its wall-clock limit.

Behaviour under CI

Both init sites already had correct non-interactive branches in their else; only the gate selecting them was wrong.

  • init/index.ts — skips the offer and prints the existing plan --target steering outro.
  • resolve-proxy-choice.ts — returns usesProxy: false with an info log. That default is unchanged, not new: it is the prompt's own initialValue and what cancelling already produced. The --proxy/--no-proxy short-circuit is untouched.

Verification

Reverting each gate fails the corresponding tests: 6 for the init sites (all three CI spellings × 2 sites), 2 for impl (CI=1, CI=TRUE — that gate did catch CI=true). Both counts are what the respective bugs predict.

Worth noting: the first version of the init tests stubbed only process.stdin.isTTY and passed against the reverted code, because vitest's own process.stdout.isTTY is already false — so the old stdout gate skipped the prompt for an unrelated reason. A real CI runner has both streams as TTYs. Both test files now force both, with a comment explaining why.

Full suite: 52 files, 598 tests passed (up from 587), no pre-existing test modified. code:check exits 0 at the existing baseline.

Note

resolve-proxy-choice is the only one of the three where the non-interactive path makes a behavioural choice rather than skipping a nicety — a CI stash init without --proxy now reliably gets SDK-only config with only an info log. Already true for piped runs; this extends it to CI-with-TTY. Left as-is: the Proxy is a v2-era concern and SDK-direct is the v3 path, so erroring would break CI runs that have no opinion about it.

No skills/* update — commands, flags and prompt text are unchanged; only the condition under which an existing prompt renders.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Fixed stash impl and stash init hanging on CI runners when a TTY is attached.
    • Improved CI detection so interactive prompts are skipped consistently, regardless of CI value casing/format.
    • Adjusted prompt behavior: confirmation/chain offers and proxy-vs-SDK selection are only shown in safe interactive conditions; otherwise defaults to SDK-only.
  • Tests
    • Added CI/TYTTY scenarios, prompt-cancellation coverage, and regression checks to ensure non-interactive runs never attempt blocked prompts.

tobyhede added 2 commits July 20, 2026 11:44
`stash impl` gated prompting on an inline
`Boolean(process.stdin.isTTY) && process.env.CI !== 'true'`, which only
recognises the exact lowercase spelling of CI. On a runner that sets
`CI=1` or `CI=TRUE` and allocates a TTY, `stash plan` correctly took the
non-interactive path but `stash impl` believed it was interactive and
blocked on the plan-summary confirm, or on the agent-target picker
(clack `select` reads /dev/tty). A hang, not an error.

Use `isInteractive()` from `config/tty.ts` — the same helper `stash plan`
already uses, whose `isCiEnv()` accepts `1`/`true` in any case.

Unit test pins the behaviour across CI=1, CI=TRUE, CI=true and CI unset.
Both `stash init` prompts gated on `process.stdout.isTTY` and consulted
CI not at all, so a runner with an allocated TTY rendered the prompt and
blocked on /dev/tty forever — strictly more exposed than the `stash impl`
bug, since neither even caught `CI=true`:

- init/index.ts: the confirm offering to chain into `stash plan`
- steps/resolve-proxy-choice.ts: the Proxy-vs-SDK select

Gating on stdout was also the wrong stream: a redirected stdin still
hangs a prompt. Both now use `isInteractive()` from config/tty.ts.

Both files already had correct non-interactive branches, so this is a
condition swap only — no reshaping of the init flow. Non-interactive
init skips the chain offer and prints the `plan --target` hint; the
proxy question defaults to SDK-only, which is what the interactive
prompt defaults to and what cancelling it already produced.

The tests force isTTY on BOTH streams: a CI runner that allocates a TTY
has stdin and stdout both TTYs, and stubbing stdin alone would let the
old stdout gate pass for the wrong reason, since vitest's own stdout is
not a TTY. Verified by reverting both gates — all six CI cases fail.
@tobyhede
tobyhede requested a review from a team as a code owner July 20, 2026 02:47
@changeset-bot

changeset-bot Bot commented Jul 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: e5eb696

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
Name Type
stash Patch
@cipherstash/basic-example Patch
@cipherstash/e2e Patch
@cipherstash/stack Patch
@cipherstash/stack-drizzle Patch
@cipherstash/stack-supabase Patch
@cipherstash/prisma-next Patch
@cipherstash/wizard Patch
@cipherstash/bench Patch
@cipherstash/test-kit Patch
@cipherstash/prisma-next-example Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@coderabbitai

coderabbitai Bot commented Jul 20, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The CLI now uses shared TTY and CI detection for stash impl and stash init, preventing CI prompts from blocking. Tests cover multiple CI values, non-TTY execution, prompt cancellation, and interactive TTY behavior.

Changes

TTY and CI interactivity

Layer / File(s) Summary
Impl prompt gating
packages/cli/src/commands/impl/index.ts, packages/cli/src/commands/impl/__tests__/impl.test.ts, .changeset/hungry-spoons-shake.md
implCommand uses isInteractive() and skips confirmation and handoff steps for CI values 1, TRUE, and true; the changeset documents the updated behavior.
Init chain-offer gating
packages/cli/src/commands/init/index.ts, packages/cli/src/commands/init/__tests__/init-command.test.ts
initCommand uses shared interactivity detection to suppress the stash plan chain offer in CI while retaining it for interactive TTY sessions.
Proxy choice fallback
packages/cli/src/commands/init/steps/resolve-proxy-choice.ts, packages/cli/src/commands/init/steps/__tests__/resolve-proxy-choice.test.ts
Proxy selection avoids prompting in non-interactive contexts and defaults to SDK-only mode, with coverage for flags, CI, cancellation, and non-TTY execution.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

  • cipherstash/stack#446: Addresses related prevention of /dev/tty prompt blocking in non-interactive environments.
  • cipherstash/stack#569: Also updates CLI CI/TTY interactivity gating through shared TTY utilities.

Suggested reviewers: auxesis

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: routing stash impl and init prompts through the shared TTY gate.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/impl-tty-detection

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

The flag-consent path was the one prompt on `stash impl` that never went
through `isInteractive()`. With no plan on disk and `--continue-without-plan`
passed, a CI runner still reached `p.confirm`, which reads /dev/tty — the same
hang the rest of this branch closes. Worse if the prompt did resolve: the
confirm is `initialValue: false`, so a non-interactive run would cancel work
the flag had explicitly authorised.

The flag is the consent. Take it as given when non-interactive; keep the
interactive confirmation unchanged.
@tobyhede
tobyhede requested a review from freshtonic July 20, 2026 05:10

@auxesis auxesis left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Verdict

The fix itself is right and well-argued: four ad-hoc prompt gates collapse onto the shared isInteractive() helper, which corrects both defects at once — the CI spelling (isCiEnv() accepts 1/true in any case, where the inline CI !== 'true' did not) and the stream (stdin, not stdout). The comments left at each call site explain why rather than what, and the new suites force both streams to TTY so the old process.stdout.isTTY gate can't pass for the wrong reason. That's the right paranoia.

The consolidated concern is coverage shape, not correctness. Nearly every new test asserts the non-interactive arm (prompt not reached, handoff not run). Two things follow:

  1. The stream half of the fix is unverified. Both setTty() and the init-command.test.ts beforeEach move stdin and stdout together, so no test expresses stdin=false, stdout=true — the exact configuration (stash init < /dev/null from a terminal) that distinguishes the new gate from the old one. Right now only the CI axis actually discriminates old from new; process.stdout.isTTY could be reinstated in either file and these suites stay green.
  2. The interactive arms the gate change made reachable are largely unlocked — the --continue-without-plan confirm, the chain-offer accept path, and the new CI + TTY route into exit(1).

None of this blocks merge. 8 gaps inline, ordered by confidence.

Additional findings not posted inline

  • init-command.test.ts: the non-interactive-by-stdin path (stdin not a TTY, CI unset) never asserts the plan --target outro — only the CI-with-TTY loop does. Both reach the same else, but a regression that made isInteractive() CI-only would go unnoticed.
  • resolve-proxy-choice.test.ts:87: the interactive prompt is only exercised with select resolving true. Resolving false (the initialValue, and by far the common answer) has no test — and false is the same value the non-interactive default produces, so nothing distinguishes "user chose SDK" from "prompt was skipped".
  • impl.test.ts: the plan-exists + non-interactive branch logs Plan at `.cipherstash/plan.md` — agent will execute it as the source of truth. Nothing asserts that message, so a non-interactive run could go silent about which plan it's about to execute.

Out of scope, noted only

Not a coverage point: --continue-without-plan is now the sole consent under CI, a deliberate loosening of a default-no safety confirm. Worth a sanity check that the flag can only arrive from argv — not from .cipherstash/context.json or an env var — since the prompt that used to backstop it is gone in automation.

Review stats

Source Model Type Raw Kept
claude claude-opus-4-8 test-gap 7 7
codex gpt-5.5 test-gap 3 3
  • Unique findings after de-duplication: 8 (all 8 posted inline; nothing dropped for the cap — the 3 bullets above are the sources' own secondary lists).
  • Cross-model overlap: 3 of 8 kept findings were corroborated by 2+ models — the redirected-stdin gap in resolve-proxy-choice, the same gap in the init chain offer, and the untested non-interactive handoff success path.
  • Dropped as unverifiable: 0. Every finding from both sources was substantiated against the diff.

Comment thread packages/cli/src/commands/init/__tests__/init-command.test.ts
Comment thread packages/cli/src/commands/impl/__tests__/impl.test.ts
Comment thread packages/cli/src/commands/impl/index.ts
Comment thread packages/cli/src/commands/impl/__tests__/impl.test.ts
Comment thread packages/cli/src/commands/init/__tests__/init-command.test.ts
Comment thread packages/cli/src/commands/impl/index.ts
Adds the tests auxesis flagged as missing on #704, each verified against
the implementation:

- resolve-proxy-choice: stdin-piped/stdout-TTY split (the "wrong stream"
  claim) and the --no-proxy (usesProxy:false) short-circuit.
- init chain offer: redirected-stdin skip, and the accept arm that chains
  into `stash plan` (planCommand + early return).
- impl: still runs under CI=1 with --target (plus a --continue-without-plan
  variant), the interactive confirm + decline-aborts path, and the
  no-plan/no-flag reroute into the exit(1) branch.
- impl PTY e2e: real-hang reproducer under a genuine TTY with CI=1/TRUE
  (the headline changeset claim; the unit tests only lock the boolean).

Test-only change; no changeset required.

@auxesis auxesis left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for addressing the feedback @tobyhede 🙏

@tobyhede
tobyhede merged commit 9540ec9 into main Jul 21, 2026
9 of 10 checks passed
@tobyhede
tobyhede deleted the fix/impl-tty-detection branch July 21, 2026 02:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

stash impl and two stash init prompts hang on CI runners that allocate a TTY

2 participants