···
3
3
import fs from "node:fs/promises"
4
4
import os from "node:os"
5
5
import path from "node:path"
6
6
-
import { closeBash } from "./shell"
6
6
+
import { cleanOutput, closeBash } from "./shell"
7
7
import { runCommand } from "./tools"
8
8
9
9
test.afterEach(() => {
···
18
18
)
19
19
20
20
assert.equal(output, "cat\n0\ncat\nFRX")
21
21
+
})
22
22
+
23
23
+
test("shell recovers when a command leaves terminal echo enabled", async () => {
24
24
+
assert.equal(await runCommand("stty echo; echo first", 0, 5_000), "first")
25
25
+
assert.equal(await runCommand("echo second", 0, 5_000), "second")
26
26
+
})
27
27
+
28
28
+
test("cleanOutput collapses terminal redraw controls", () => {
29
29
+
assert.equal(cleanOutput("one\rspinner\rdone\n"), "done\n")
30
30
+
assert.equal(cleanOutput("abc\b \bd\n"), "abd\n")
31
31
+
assert.equal(cleanOutput("\x1b[?25lhidden\x1b[?25h\n"), "hidden\n")
21
32
})
22
33
23
34
test("sudo commands run as one-off commands with stdin closed", async () => {
···
10
10
} from "./config"
11
11
import type { RunRawOptions } from "./types"
12
12
13
13
+
function applyTerminalControls(str: string): string {
14
14
+
let out = ""
15
15
+
16
16
+
for (let i = 0; i < str.length; i += 1) {
17
17
+
const ch = str[i]
18
18
+
19
19
+
if (ch === "\r") {
20
20
+
if (str[i + 1] === "\n") {
21
21
+
out += "\n"
22
22
+
i += 1
23
23
+
} else {
24
24
+
const lineStart = out.lastIndexOf("\n") + 1
25
25
+
out = out.slice(0, lineStart)
26
26
+
}
27
27
+
continue
28
28
+
}
29
29
+
30
30
+
if (ch === "\b" || ch === "\x7f") {
31
31
+
if (out.length > 0 && out[out.length - 1] !== "\n") out = out.slice(0, -1)
32
32
+
continue
33
33
+
}
34
34
+
35
35
+
if (ch === "\x00") continue
36
36
+
out += ch
37
37
+
}
38
38
+
39
39
+
return out
40
40
+
}
41
41
+
13
42
/**
14
14
-
* Strip ANSI/VT escape sequences and normalize line endings from PTY output.
15
15
-
* PTYs emit CRLF and control sequences that we don't want in command results.
43
43
+
* Strip ANSI/VT escape sequences and apply simple terminal line controls.
44
44
+
* PTYs emit redraw-oriented output (CR, backspace, ANSI cursor commands) that
45
45
+
* should not be preserved as literal command result text.
16
46
*/
17
17
-
function cleanOutput(str: string): string {
18
18
-
return str
19
19
-
.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, "") // CSI sequences (colors, cursor, etc.)
47
47
+
export function cleanOutput(str: string): string {
48
48
+
const withoutEscapes = str
49
49
+
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "") // CSI sequences (colors, cursor, erase, etc.)
20
50
.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, "") // OSC sequences (title, etc.)
21
51
.replace(/\x1b[^[\]]/g, "") // other 2-char ESC sequences
22
52
.replace(/(?:\x03|\^C)\s*/g, "") // Ctrl+C echo after interrupt
23
23
-
.replace(/\r\n/g, "\n") // CRLF -> LF
24
24
-
.replace(/\r/g, "\n") // stray CR -> LF
25
25
-
.replace(/\x00/g, "") // null bytes
53
53
+
54
54
+
return applyTerminalControls(withoutEscapes)
55
55
+
}
56
56
+
57
57
+
function removeInternalPtyControlEchoes(str: string): string {
58
58
+
return str
59
59
+
.split("\n")
60
60
+
.filter((line) => line !== "stty -echo 2>/dev/null")
61
61
+
.join("\n")
26
62
}
27
63
28
64
let bash: pty.IPty | null = null
···
201
237
const end = cleaned.indexOf(endSentinel)
202
238
// Drop the single newline that echo adds after the start sentinel,
203
239
// then trim trailing whitespace from the command output.
204
204
-
resolve(cleaned.slice(start, end).replace(/^\n/, "").trimEnd())
240
240
+
resolve(removeInternalPtyControlEchoes(cleaned.slice(start, end).replace(/^\n/, "")).trimEnd())
205
241
}
206
242
})
207
243
···
209
245
// /dev/null so accidental interactive reads do not hang forever.
210
246
// The closing } is on its own line so heredocs inside the command still
211
247
// get their correct delimiter line.
248
248
+
//
249
249
+
// Re-assert `stty -echo` before both sentinels. Full-screen tools,
250
250
+
// prompts, and some spinners can leave terminal echo enabled; if that
251
251
+
// happens, bash echoes the sentinel input lines before executing them and
252
252
+
// completion detection fires on our own command text.
212
253
const groupedCommand = redirectStdinToDevNull ? `{ ${command}\n} < /dev/null` : `{ ${command}\n}`
213
254
214
214
-
session.write(`echo ${startSentinel}\n${groupedCommand}\necho ${endSentinel}\n`)
255
255
+
session.write(
256
256
+
[
257
257
+
"stty -echo 2>/dev/null",
258
258
+
`echo ${startSentinel}`,
259
259
+
groupedCommand,
260
260
+
"stty -echo 2>/dev/null",
261
261
+
`echo ${endSentinel}`,
262
262
+
"",
263
263
+
].join("\n"),
264
264
+
)
215
265
216
266
const timer = setTimeout(() => {
217
267
if (settled) return