From 79d945a13c372c41b46a0d24c7a4747ce143a4f1 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sun, 8 Mar 2026 19:56:26 +0000 Subject: [PATCH 1/3] fix(shell): guard TUI render against non-TTY Docker environments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ink's render() calls setRawMode(true) on mount. When docker-git runs without a pseudo-TTY (docker exec without -t, CI, piped stdin), this throws "Raw mode is not supported" and waitUntilExit() never resolves, causing an infinite hang. - Wrap render() in Effect.suspend and check process.stdin.isTTY and typeof process.stdin.setRawMode before calling render() - Fail immediately with a descriptive InputReadError guiding the user to attach a terminal (ssh or docker run -it) Invariant: ∀ env: ¬isTTY(env) → fail(InputReadError) ∧ ¬hang Closes #100 Co-Authored-By: Claude Sonnet 4.6 --- packages/app/src/docker-git/menu.ts | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/packages/app/src/docker-git/menu.ts b/packages/app/src/docker-git/menu.ts index 5d458b3b..d6689e9d 100644 --- a/packages/app/src/docker-git/menu.ts +++ b/packages/app/src/docker-git/menu.ts @@ -282,14 +282,34 @@ const TuiApp = () => { // EFFECT: Effect // INVARIANT: app exits only on Quit or ctrl+c // COMPLEXITY: O(1) per input +// +// CHANGE: guard against non-TTY environments (Docker without -t) +// WHY: Ink calls setRawMode(true) on mount — without a TTY stdin does not support +// raw mode, causing an unhandled error and a hang in waitUntilExit(). +// Fail fast with a descriptive error instead. +// QUOTE(ТЗ): "вечный цикл зависания на TUI из за ошибки Raw mode is not supported" +// REF: issue-100 +// SOURCE: https://github.com/vadimdemedes/ink/#israwmodesupported +// FORMAT THEOREM: ∀ env: ¬isTTY(env) → fail(InputReadError) ∧ ¬hang +// INVARIANT: render() is only called when stdin.isTTY ∧ setRawMode ∈ stdin export const runMenu = pipe( Effect.sync(() => { resumeTui() }), Effect.zipRight( - Effect.tryPromise({ - try: () => render(React.createElement(TuiApp)).waitUntilExit(), - catch: (error) => new InputReadError({ message: error instanceof Error ? error.message : String(error) }) + Effect.suspend(() => { + if (!process.stdin.isTTY || typeof process.stdin.setRawMode !== "function") { + return Effect.fail( + new InputReadError({ + message: + "TUI requires a TTY. Attach a terminal: ssh into the container or use `docker run -it`." + }) + ) + } + return Effect.tryPromise({ + try: () => render(React.createElement(TuiApp)).waitUntilExit(), + catch: (error) => new InputReadError({ message: error instanceof Error ? error.message : String(error) }) + }) }) ), Effect.ensuring( From 7963a8779a90b0308b6304c1fec46c62dafb6cb5 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Sun, 8 Mar 2026 20:18:14 +0000 Subject: [PATCH 2/3] fix(shell): fall back to listProjectStatus when no TTY available MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of failing with InputReadError, docker-git menu now runs listProjectStatus in non-interactive environments (Docker without -t, CI, piped stdin). Zero errors for the user. ∀ env: isTTY(env) → TUI, ¬isTTY(env) → listProjectStatus ∧ exit 0 Co-Authored-By: Claude Sonnet 4.6 --- packages/app/src/docker-git/menu.ts | 50 +++++++++++++---------------- 1 file changed, 23 insertions(+), 27 deletions(-) diff --git a/packages/app/src/docker-git/menu.ts b/packages/app/src/docker-git/menu.ts index d6689e9d..2221aac3 100644 --- a/packages/app/src/docker-git/menu.ts +++ b/packages/app/src/docker-git/menu.ts @@ -1,7 +1,7 @@ import { runDockerPsNames } from "@effect-template/lib/shell/docker" import { type InputCancelledError, InputReadError } from "@effect-template/lib/shell/errors" import { type AppError, renderError } from "@effect-template/lib/usecases/errors" -import { listProjectItems } from "@effect-template/lib/usecases/projects" +import { listProjectItems, listProjectStatus } from "@effect-template/lib/usecases/projects" import { NodeContext } from "@effect/platform-node" import { Effect, pipe } from "effect" import { render, useApp, useInput } from "ink" @@ -286,38 +286,34 @@ const TuiApp = () => { // CHANGE: guard against non-TTY environments (Docker without -t) // WHY: Ink calls setRawMode(true) on mount — without a TTY stdin does not support // raw mode, causing an unhandled error and a hang in waitUntilExit(). -// Fail fast with a descriptive error instead. +// Fall back to listProjectStatus in non-interactive environments. // QUOTE(ТЗ): "вечный цикл зависания на TUI из за ошибки Raw mode is not supported" // REF: issue-100 // SOURCE: https://github.com/vadimdemedes/ink/#israwmodesupported -// FORMAT THEOREM: ∀ env: ¬isTTY(env) → fail(InputReadError) ∧ ¬hang +// FORMAT THEOREM: ∀ env: isTTY(env) → renderTui ∧ ¬isTTY(env) → listProjectStatus // INVARIANT: render() is only called when stdin.isTTY ∧ setRawMode ∈ stdin -export const runMenu = pipe( - Effect.sync(() => { - resumeTui() - }), - Effect.zipRight( - Effect.suspend(() => { - if (!process.stdin.isTTY || typeof process.stdin.setRawMode !== "function") { - return Effect.fail( - new InputReadError({ - message: - "TUI requires a TTY. Attach a terminal: ssh into the container or use `docker run -it`." - }) - ) - } - return Effect.tryPromise({ +export const runMenu = Effect.suspend(() => { + if (!process.stdin.isTTY || typeof process.stdin.setRawMode !== "function") { + return listProjectStatus + } + + return pipe( + Effect.sync(() => { + resumeTui() + }), + Effect.zipRight( + Effect.tryPromise({ try: () => render(React.createElement(TuiApp)).waitUntilExit(), catch: (error) => new InputReadError({ message: error instanceof Error ? error.message : String(error) }) }) - }) - ), - Effect.ensuring( - Effect.sync(() => { - leaveTui() - }) - ), - Effect.asVoid -) + ), + Effect.ensuring( + Effect.sync(() => { + leaveTui() + }) + ), + Effect.asVoid + ) +}) export type MenuError = AppError | InputCancelledError From c54969ffe71d7c2f4dabe39392e2534b762c78b5 Mon Sep 17 00:00:00 2001 From: skulidropek <66840575+skulidropek@users.noreply.github.com> Date: Mon, 9 Mar 2026 07:56:36 +0000 Subject: [PATCH 3/3] fix(shell): unify Effect types in runMenu TTY fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace Effect.suspend (union of incompatible branches) with Effect.flatMap on a boolean so TypeScript unifies the two branch types: Effect | Effect → Effect. Co-Authored-By: Claude Sonnet 4.6 --- packages/app/src/docker-git/menu.ts | 45 +++++++++++++++-------------- 1 file changed, 23 insertions(+), 22 deletions(-) diff --git a/packages/app/src/docker-git/menu.ts b/packages/app/src/docker-git/menu.ts index 2221aac3..b314f95d 100644 --- a/packages/app/src/docker-git/menu.ts +++ b/packages/app/src/docker-git/menu.ts @@ -292,28 +292,29 @@ const TuiApp = () => { // SOURCE: https://github.com/vadimdemedes/ink/#israwmodesupported // FORMAT THEOREM: ∀ env: isTTY(env) → renderTui ∧ ¬isTTY(env) → listProjectStatus // INVARIANT: render() is only called when stdin.isTTY ∧ setRawMode ∈ stdin -export const runMenu = Effect.suspend(() => { - if (!process.stdin.isTTY || typeof process.stdin.setRawMode !== "function") { - return listProjectStatus - } - - return pipe( - Effect.sync(() => { - resumeTui() - }), - Effect.zipRight( - Effect.tryPromise({ - try: () => render(React.createElement(TuiApp)).waitUntilExit(), - catch: (error) => new InputReadError({ message: error instanceof Error ? error.message : String(error) }) - }) - ), - Effect.ensuring( - Effect.sync(() => { - leaveTui() - }) - ), - Effect.asVoid +export const runMenu = pipe( + Effect.sync(() => process.stdin.isTTY && typeof process.stdin.setRawMode === "function"), + Effect.flatMap((hasTty) => + hasTty + ? pipe( + Effect.sync(() => { + resumeTui() + }), + Effect.zipRight( + Effect.tryPromise({ + try: () => render(React.createElement(TuiApp)).waitUntilExit(), + catch: (error) => new InputReadError({ message: error instanceof Error ? error.message : String(error) }) + }) + ), + Effect.ensuring( + Effect.sync(() => { + leaveTui() + }) + ), + Effect.asVoid + ) + : Effect.ignore(listProjectStatus) ) -}) +) export type MenuError = AppError | InputCancelledError