Skip to content

Commit 35b6c20

Browse files
authored
Merge pull request #228 from ProverCoderAI/fix/ci-docker-git-checks
fix(ci): stabilize docker-git checks
2 parents b77d36e + bfda21a commit 35b6c20

19 files changed

Lines changed: 1459 additions & 1203 deletions

.github/workflows/check.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,21 @@ jobs:
2828
- name: Build (api)
2929
run: bun run --cwd packages/api build
3030

31+
dist-deps-prune:
32+
name: Dist deps prune
33+
runs-on: ubuntu-latest
34+
timeout-minutes: 10
35+
steps:
36+
- uses: actions/checkout@v6
37+
- name: Install dependencies
38+
uses: ./.github/actions/setup
39+
with:
40+
node-version: 24.14.0
41+
- name: Build package
42+
run: bun run --cwd packages/app build
43+
- name: Dist deps prune (lint)
44+
run: bun run check:dist-deps-prune
45+
3146
types:
3247
name: Types
3348
runs-on: ubuntu-latest
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
/* jscpd:ignore-start */
2+
import * as Command from "@effect/platform/Command"
3+
import type * as CommandExecutor from "@effect/platform/CommandExecutor"
4+
import * as FileSystem from "@effect/platform/FileSystem"
5+
import { Effect, Option, pipe } from "effect"
6+
7+
const terminalSaneEscape = "\u001B[0m" + // reset rendition
8+
"\u001B[?25h" + // show cursor
9+
"\u001B[?1l" + // normal cursor keys mode
10+
"\u001B>" + // normal keypad mode
11+
"\u001B[?1000l" + // disable mouse click tracking
12+
"\u001B[?1002l" + // disable mouse drag tracking
13+
"\u001B[?1003l" + // disable any-event mouse tracking
14+
"\u001B[?1005l" + // disable UTF-8 mouse mode
15+
"\u001B[?1006l" + // disable SGR mouse mode
16+
"\u001B[?1015l" + // disable urxvt mouse mode
17+
"\u001B[?1007l" + // disable alternate scroll mode
18+
"\u001B[?1004l" + // disable focus reporting
19+
"\u001B[?2004l" + // disable bracketed paste
20+
"\u001B[>4;0m" + // disable xterm modifyOtherKeys
21+
"\u001B[>4m" + // reset xterm modifyOtherKeys
22+
"\u001B[<u" // disable kitty keyboard protocol
23+
24+
const controllingTtyPath = "/dev/tty"
25+
const shellPath = "/bin/sh"
26+
const sttyPath = "/usr/bin/stty"
27+
const snapshotPattern = /^[0-9a-fA-F:]+$/u
28+
29+
export type TerminalCursorRuntime = CommandExecutor.CommandExecutor | FileSystem.FileSystem
30+
export type TerminalResetFallbackWrite = (chunk: string) => void
31+
32+
const optionOrElse = <A>(option: Option.Option<A>, fallback: A): A => pipe(option, Option.getOrElse(() => fallback))
33+
34+
const succeeds = <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<boolean, never, R> =>
35+
pipe(
36+
effect,
37+
Effect.as(true),
38+
Effect.option,
39+
Effect.map((result) => optionOrElse(result, false))
40+
)
41+
42+
const hasInteractiveTty = (): boolean => process.stdin.isTTY && process.stdout.isTTY
43+
44+
const disableRawMode = (): Effect.Effect<void> => {
45+
if (typeof process.stdin.setRawMode !== "function") {
46+
return Effect.void
47+
}
48+
49+
return pipe(
50+
Effect.try(() => {
51+
process.stdin.setRawMode(false)
52+
}),
53+
Effect.ignore
54+
)
55+
}
56+
57+
const ttyShellCommand = (script: string): Command.Command =>
58+
pipe(
59+
Command.make(shellPath, "-c", script),
60+
Command.stdin("inherit"),
61+
Command.stdout("pipe"),
62+
Command.stderr("pipe")
63+
)
64+
65+
const runTtyShell = (script: string): Effect.Effect<boolean, never, CommandExecutor.CommandExecutor> =>
66+
pipe(
67+
ttyShellCommand(script),
68+
Command.exitCode,
69+
Effect.map((exitCode) => Number(exitCode) === 0),
70+
Effect.option,
71+
Effect.map((result) => optionOrElse(result, false))
72+
)
73+
74+
const runTtyShellString = (script: string): Effect.Effect<string, never, CommandExecutor.CommandExecutor> =>
75+
pipe(
76+
ttyShellCommand(script),
77+
Command.string,
78+
Effect.map((output) => output.trim()),
79+
Effect.option,
80+
Effect.map((result) => optionOrElse(result, ""))
81+
)
82+
83+
const snapshotTerminalState = (): Effect.Effect<string | null, never, CommandExecutor.CommandExecutor> => {
84+
if (!hasInteractiveTty()) {
85+
return Effect.succeed(null)
86+
}
87+
88+
return Effect.gen(function*(_) {
89+
yield* _(disableRawMode())
90+
const snapshot = yield* _(
91+
runTtyShellString(
92+
`if [ -c ${controllingTtyPath} ]; then ${sttyPath} -g < ${controllingTtyPath} 2>/dev/null || true; fi`
93+
)
94+
)
95+
return snapshotPattern.test(snapshot) ? snapshot : null
96+
})
97+
}
98+
99+
const writeTerminalReset = (
100+
fallbackWrite?: TerminalResetFallbackWrite
101+
): Effect.Effect<boolean, never, FileSystem.FileSystem> =>
102+
Effect.gen(function*(_) {
103+
const fs = yield* _(FileSystem.FileSystem)
104+
const wroteTty = yield* _(succeeds(fs.writeFileString(controllingTtyPath, terminalSaneEscape)))
105+
if (wroteTty) {
106+
return true
107+
}
108+
109+
if (fallbackWrite !== undefined) {
110+
return yield* _(
111+
succeeds(
112+
Effect.try(() => {
113+
fallbackWrite(terminalSaneEscape)
114+
})
115+
)
116+
)
117+
}
118+
119+
return yield* _(
120+
succeeds(
121+
Effect.try(() => {
122+
process.stdout.write(terminalSaneEscape)
123+
})
124+
)
125+
)
126+
})
127+
128+
const runSttySane = (): Effect.Effect<boolean, never, CommandExecutor.CommandExecutor> =>
129+
runTtyShell(
130+
`if [ -c ${controllingTtyPath} ]; then ${sttyPath} sane < ${controllingTtyPath} > ${controllingTtyPath} 2>/dev/null; else exit 1; fi`
131+
)
132+
133+
const restoreSttySnapshot = (snapshot: string): Effect.Effect<boolean, never, CommandExecutor.CommandExecutor> =>
134+
snapshotPattern.test(snapshot)
135+
? runTtyShell(
136+
`if [ -c ${controllingTtyPath} ]; then ${sttyPath} '${snapshot}' < ${controllingTtyPath} > ${controllingTtyPath} 2>/dev/null; else exit 1; fi`
137+
)
138+
: Effect.succeed(false)
139+
140+
// CHANGE: share the low-level tty repair across SSH launch and TUI suspend/resume
141+
// WHY: both paths must reset the same controlling terminal before interactive output
142+
// QUOTE(ТЗ): "при подключении по SSH контейнер забаганный. Кривокосо печатается текст"
143+
// REF: user-request-2026-04-20-menu-select-ssh-terminal
144+
// SOURCE: n/a
145+
// FORMAT THEOREM: forall t: interactive(t) -> sane_tty(t)
146+
// PURITY: SHELL
147+
// EFFECT: Effect<void, never, TerminalCursorRuntime>
148+
// INVARIANT: fallback writer is used only when /dev/tty repair is unavailable
149+
// COMPLEXITY: O(1)
150+
export const repairInteractiveTerminal = (
151+
fallbackWrite?: TerminalResetFallbackWrite
152+
): Effect.Effect<void, never, TerminalCursorRuntime> => {
153+
if (!hasInteractiveTty()) {
154+
return Effect.void
155+
}
156+
157+
return Effect.gen(function*(_) {
158+
yield* _(disableRawMode())
159+
const sane = yield* _(runSttySane())
160+
const wroteReset = sane ? yield* _(writeTerminalReset(fallbackWrite)) : false
161+
if (!wroteReset) {
162+
yield* _(writeTerminalReset(fallbackWrite))
163+
}
164+
})
165+
}
166+
167+
const restoreTerminalState = (
168+
snapshot: string | null
169+
): Effect.Effect<void, never, TerminalCursorRuntime> => {
170+
if (!hasInteractiveTty()) {
171+
return Effect.void
172+
}
173+
174+
return Effect.gen(function*(_) {
175+
yield* _(disableRawMode())
176+
const restored = snapshot === null ? false : yield* _(restoreSttySnapshot(snapshot))
177+
if (!restored) {
178+
yield* _(runSttySane())
179+
}
180+
yield* _(writeTerminalReset())
181+
})
182+
}
183+
184+
// CHANGE: ensure the terminal cursor is visible before handing control to interactive SSH
185+
// WHY: Ink/TTY transitions can leave cursor hidden, which makes SSH shells look frozen
186+
// QUOTE(ТЗ): "не виден курсор в SSH терминале"
187+
// REF: issue-3
188+
// SOURCE: n/a
189+
// FORMAT THEOREM: forall t: interactive(t) -> cursor_visible(t)
190+
// PURITY: SHELL
191+
// EFFECT: Effect<void, never, TerminalCursorRuntime>
192+
// INVARIANT: escape sequence is emitted only in interactive tty mode
193+
// COMPLEXITY: O(1)
194+
export const ensureTerminalCursorVisible = (): Effect.Effect<void, never, TerminalCursorRuntime> =>
195+
repairInteractiveTerminal()
196+
197+
export const withPreservedTerminalState = <A, E, R>(
198+
use: Effect.Effect<A, E, R>
199+
): Effect.Effect<A, E, R | TerminalCursorRuntime> =>
200+
Effect.gen(function*(_) {
201+
const snapshot = yield* _(snapshotTerminalState())
202+
yield* _(ensureTerminalCursorVisible())
203+
return yield* _(use.pipe(Effect.ensuring(restoreTerminalState(snapshot))))
204+
})
205+
/* jscpd:ignore-end */

packages/app/src/docker-git/menu-shared.ts

Lines changed: 72 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { MenuViewContext, ViewState } from "./menu-types.js"
22

33
import { Effect, pipe } from "effect"
4-
import { repairInteractiveTerminal } from "../lib/usecases/terminal-cursor.js"
4+
import { repairInteractiveTerminal, type TerminalCursorRuntime } from "./frontend-lib/shell/terminal-cursor.js"
55

66
// CHANGE: share menu escape handling across flows
77
// WHY: avoid duplicated logic in TUI handlers
@@ -146,19 +146,23 @@ export const withSuspendedTui = <A, E, R>(
146146
readonly onError?: (error: E) => Effect.Effect<void>
147147
readonly onResume?: () => void
148148
}
149-
): Effect.Effect<A, E, R> => {
149+
): Effect.Effect<A, E, R | TerminalCursorRuntime> => {
150150
const withError = options?.onError
151151
? pipe(effect, Effect.tapError((error) => Effect.ignore(options.onError?.(error) ?? Effect.void)))
152152
: effect
153153

154154
return pipe(
155-
Effect.sync(suspendTui),
155+
suspendTui(),
156156
Effect.zipRight(withError),
157157
Effect.ensuring(
158-
Effect.sync(() => {
159-
resumeTui()
160-
options?.onResume?.()
161-
})
158+
pipe(
159+
resumeTui(),
160+
Effect.zipRight(
161+
Effect.sync(() => {
162+
options?.onResume?.()
163+
})
164+
)
165+
)
162166
)
163167
)
164168
}
@@ -199,6 +203,36 @@ const setStdoutMuted = (muted: boolean): void => {
199203
stdoutMuted = muted
200204
}
201205

206+
const setStdoutMutedEffect = (muted: boolean): Effect.Effect<void> =>
207+
Effect.sync(() => {
208+
setStdoutMuted(muted)
209+
})
210+
211+
const writeTerminalControlEffect = (text: string): Effect.Effect<void> =>
212+
Effect.sync(() => {
213+
writeTerminalControl(text)
214+
})
215+
216+
const setRawModeEffect = (enabled: boolean): Effect.Effect<void> =>
217+
process.stdin.isTTY && typeof process.stdin.setRawMode === "function"
218+
? pipe(
219+
Effect.try(() => {
220+
process.stdin.setRawMode(enabled)
221+
}),
222+
Effect.ignore
223+
)
224+
: Effect.void
225+
226+
const whenStdoutTty = (effect: Effect.Effect<void, never, TerminalCursorRuntime>) =>
227+
process.stdout.isTTY ? effect : Effect.void
228+
229+
const preparePrimaryScreen = (): Effect.Effect<void, never, TerminalCursorRuntime> =>
230+
Effect.gen(function*(_) {
231+
yield* _(setStdoutMutedEffect(true))
232+
yield* _(repairInteractiveTerminal(writeTerminalControl))
233+
yield* _(writeTerminalControlEffect(primaryScreenEscape))
234+
})
235+
202236
// CHANGE: temporarily suspend TUI rendering when running interactive commands
203237
// WHY: avoid mixed output from docker/ssh and the Ink UI
204238
// QUOTE(ТЗ): "Почему так кривокосо всё отображается?"
@@ -209,16 +243,14 @@ const setStdoutMuted = (muted: boolean): void => {
209243
// EFFECT: n/a
210244
// INVARIANT: only toggles when TTY is available
211245
// COMPLEXITY: O(1)
212-
export const suspendTui = (): void => {
213-
if (!process.stdout.isTTY) {
214-
return
215-
}
216-
setStdoutMuted(true)
217-
repairInteractiveTerminal(writeTerminalControl)
218-
// Switch back to the primary screen so interactive commands (ssh/gh/codex)
219-
// can render normally. Do not clear it: users may need scrollback (OAuth codes/URLs).
220-
writeTerminalControl(primaryScreenEscape)
221-
}
246+
export const suspendTui = (): Effect.Effect<void, never, TerminalCursorRuntime> =>
247+
whenStdoutTty(
248+
preparePrimaryScreen().pipe(
249+
// Switch back to the primary screen so interactive commands (ssh/gh/codex)
250+
// can render normally. Do not clear it: users may need scrollback (OAuth codes/URLs).
251+
Effect.asVoid
252+
)
253+
)
222254

223255
// CHANGE: restore TUI rendering after interactive commands
224256
// WHY: return to Ink UI without broken terminal state
@@ -230,34 +262,30 @@ export const suspendTui = (): void => {
230262
// EFFECT: n/a
231263
// INVARIANT: only toggles when TTY is available
232264
// COMPLEXITY: O(1)
233-
export const resumeTui = (): void => {
234-
if (!process.stdout.isTTY) {
235-
return
236-
}
237-
repairInteractiveTerminal(writeTerminalControl)
238-
// Return to the alternate screen for Ink rendering.
239-
writeTerminalControl("\u001B[?1049h\u001B[2J\u001B[H")
240-
if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
241-
process.stdin.setRawMode(true)
242-
}
243-
disableTerminalInputModes()
244-
setStdoutMuted(false)
245-
}
265+
export const resumeTui = (): Effect.Effect<void, never, TerminalCursorRuntime> =>
266+
whenStdoutTty(
267+
Effect.gen(function*(_) {
268+
yield* _(repairInteractiveTerminal(writeTerminalControl))
269+
// Return to the alternate screen for Ink rendering.
270+
yield* _(writeTerminalControlEffect("\u001B[?1049h\u001B[2J\u001B[H"))
271+
yield* _(setRawModeEffect(true))
272+
yield* _(Effect.sync(() => {
273+
disableTerminalInputModes()
274+
}))
275+
yield* _(setStdoutMutedEffect(false))
276+
})
277+
)
246278

247-
export const leaveTui = (): void => {
248-
if (!process.stdout.isTTY) {
249-
return
250-
}
251-
// Ensure we don't leave the terminal in a broken "mouse reporting" mode.
252-
setStdoutMuted(true)
253-
repairInteractiveTerminal(writeTerminalControl)
254-
// Restore the primary screen on exit without clearing it (keeps useful scrollback).
255-
writeTerminalControl(primaryScreenEscape)
256-
if (process.stdin.isTTY && typeof process.stdin.setRawMode === "function") {
257-
process.stdin.setRawMode(false)
258-
}
259-
setStdoutMuted(false)
260-
}
279+
export const leaveTui = (): Effect.Effect<void, never, TerminalCursorRuntime> =>
280+
whenStdoutTty(
281+
Effect.gen(function*(_) {
282+
// Ensure we don't leave the terminal in a broken "mouse reporting" mode.
283+
yield* _(preparePrimaryScreen())
284+
// Restore the primary screen on exit without clearing it (keeps useful scrollback).
285+
yield* _(setRawModeEffect(false))
286+
yield* _(setStdoutMutedEffect(false))
287+
})
288+
)
261289

262290
export const resetToMenu = (context: MenuResetContext): void => {
263291
const view: ViewState = { _tag: "Menu" }

0 commit comments

Comments
 (0)