fix: gracefully handle non-TTY context in interactive commands#675
fix: gracefully handle non-TTY context in interactive commands#675
Conversation
Interactive search commands (model search, type search, workflow search, etc.) use Ink for terminal UIs which requires raw mode on stdin. In non-TTY contexts (piped input, CI, AI agents), Ink crashes with "Raw mode is not supported". This was a pre-existing bug, but became more impactful with the introduction of datastores — the crash now also leaks the datastore lock, blocking subsequent commands for ~60 seconds. Two fixes: 1. Add `interactiveOutputMode()` helper that falls back to JSON mode when stdin is not a TTY. Applied to all 10 interactive search commands so they output structured JSON instead of crashing. 2. Add `flushDatastoreSync()` safety net in main.ts catch block so the datastore lock is released even on unexpected crashes that bypass runCli()'s own error handling. Co-authored-by: Blake Irvin <[email protected]> Co-Authored-By: Claude Opus 4.6 <[email protected]>
There was a problem hiding this comment.
Review Summary
This PR gracefully handles non-TTY contexts in interactive Ink-based search commands by falling back to JSON output mode when stdin is not a TTY. The implementation is clean, well-documented, and follows project conventions.
No Blocking Issues
All changes comply with the project's code style and architectural guidelines.
Code Quality Assessment
TypeScript/Code Style:
- No
anytypes used in new code - Named exports used correctly throughout
- License headers present on all modified files
- Clean, consistent pattern applied across all 10 interactive commands
Domain-Driven Design:
- Changes are appropriately placed in CLI/infrastructure layers
isStdinTty()is a pure infrastructure concern (terminal state detection)interactiveOutputMode()is presentation layer logic (output mode selection)- No domain logic pollution - this is proper layering
Security:
- No vulnerabilities introduced
- Adds safety via datastore lock release in
main.tscatch block isStdinTty()safely wrapsDeno.stdin.isTerminal()with try/catch
Safety Net in main.ts:
flushDatastoreSync()is correctly idempotent (nulls state immediately)- Proper error handling for each operation
- Releases lock even if S3 push fails
Suggestions (Non-blocking)
-
Unit tests for
interactiveOutputMode(): Consider adding tests for thectx.outputMode === "json"case incontext_test.ts. While testing TTY state is environment-dependent, the JSON passthrough case can be tested deterministically:Deno.test("interactiveOutputMode returns json when ctx.outputMode is json", () => { const ctx: CommandContext = { outputMode: "json", verbosity: "normal", logger: getSwampLogger([]) }; assertEquals(interactiveOutputMode(ctx), "json"); });
-
The explanatory comment in
model_search.ts:135-136is excellent - consider adding similar brief comments in other command files for consistency, though this is purely stylistic.
Verification
- CI checks pass (lint, test, format)
- 2797 tests passing
- Pattern consistently applied to all affected commands
Great fix for a subtle but important issue that affects CI/agent usability.
There was a problem hiding this comment.
Adversarial Review
Critical / High
None found.
Medium
-
Incomplete fix:
extension_search.tsnot updated (src/cli/commands/extension_search.ts:122-136)The PR updates 10 interactive search commands to use
interactiveOutputMode(), butextension searchwas missed. It still passesctx.outputModedirectly torenderExtensionSearch():const result = await renderExtensionSearch( {...}, ctx.outputMode, // Should be interactiveOutputMode(ctx) );
Breaking example:
echo "" | swamp extension search awswill crash with "Raw mode is not supported" in non-TTY contexts.Severity rationale: This is MEDIUM rather than HIGH because unlike the other search commands,
extension_searchdoesn't acquire a datastore lock until after the interactive selection completes (line 140 is insideif (result?.action === "install")). So while the command will still crash in non-TTY, it won't cause the lock leak issue that this PR primarily addresses.Suggested fix: Apply the same pattern to
extension_search.ts:import { interactiveOutputMode } from "../context.ts"; // ... const effectiveMode = interactiveOutputMode(ctx); const result = await renderExtensionSearch({...}, effectiveMode);
Low
-
Unused parameter
outputModeinRequireRepoOptions(src/cli/repo_context.ts:57-59)The
outputModeparameter inRequireRepoOptionsis passed by all commands but never used withinrequireInitializedRepo(). This is pre-existing dead code, not introduced by this PR, but the pattern of passingeffectiveModeto it (while correct semantically) doesn't actually accomplish anything.
Verdict
PASS - The core fix is sound. The interactiveOutputMode() helper correctly detects non-TTY contexts and falls back to JSON. The flushDatastoreSync() safety net in main.ts is properly idempotent (nulls globals on first call). The double-flush scenario (inside runCli() catch + main.ts catch) is handled correctly. All 10 claimed commands are updated consistently.
The missed extension_search command is a gap in coverage but doesn't undermine the PR's primary goal of preventing lock leaks, since that command doesn't hold a lock when Ink is invoked.
Follow-up to #678 (datastore lock deadlock fix) and #675 (non-TTY interactive command fix). Addresses three issues identified in adversarial review of those PRs: 1. TOCTOU race in lock release: Added `forceRelease(expectedNonce)` to the `DistributedLock` interface so each backend (S3Lock, FileLock) can verify the nonce and delete in the tightest possible sequence. The `datastore lock release` command now delegates to this method instead of branching on config type with separate delete logic. 2. Double marker file read in `requireInitializedRepo`: The refactoring in #678 caused `requireInitializedRepo` to read the marker file twice — once in `resolveDatastoreForRepo` and again directly. `resolveDatastoreForRepo` now returns the marker it already read, and `requireInitializedRepo` reuses it. 3. `extension_search.ts` missed in #675: The PR updated 10 interactive search commands to use `interactiveOutputMode()` but missed `extension_search`. Applied the same pattern so it won't crash with "Raw mode is not supported" in non-TTY contexts. Co-authored-by: Claude Opus 4.6 <[email protected]>
Fixes #674
Summary
Interactive search commands (model search, type search, workflow search, etc.) use Ink for terminal UIs which requires raw mode on stdin. In non-TTY contexts (piped input, CI, AI agents), Ink crashes with "Raw mode is not supported". This was a pre-existing bug — it existed before datastores — but the introduction of the datastore lock made it much worse: the crash now leaks the lock, blocking all subsequent swamp commands for ~60 seconds until timeout.
Fix 1 —
interactiveOutputMode()helper (src/cli/context.ts): A new function that returns"json"whenctx.outputModeis"json"OR when stdin is not a TTY. Applied surgically to all 10 interactive search commands so they fall back to structured JSON output instead of crashing. Non-interactive commands (version, model get, workflow run, etc.) are unaffected — they keep their normal output mode.Fix 2 — Safety net in
main.ts: AddedflushDatastoreSync()to the top-level catch block so the datastore lock is released even on unexpected crashes that bypassrunCli()'s own error handling.flushDatastoreSync()is idempotent (nulls its state on first call), so calling it a second time is harmless.Commands updated with
interactiveOutputMode:model search,type search,vault search,vault type searchworkflow search,workflow run search,workflow history searchmodel method history search,model output search,data searchTest Plan
deno fmt --checkpassesdeno lintpassesdeno checkpasses🤖 Generated with Claude Code