diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 034b168e52c79..2dcf239cb04b5 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -1,16 +1,20 @@ import * as NodeCrypto from "@effect/platform-node/NodeCrypto"; -import { describe, expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, expect, it } from "@effect/vitest"; import { ProjectId, TerminalNotRunningError, type TerminalEvent, type TerminalSessionSnapshot, } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; import { TestClock } from "effect/testing"; import * as ServerSettings from "../serverSettings.ts"; @@ -164,7 +168,7 @@ describe("ProjectSetupScriptRunner", () => { yield* emit({ ...event, type: "activity", hasRunningSubprocess: false, label: "zsh" }); yield* emit({ ...event, type: "output", data: "\r\n% \x1b[?2004h" }); - expect(yield* Queue.take(writes)).toMatch(/^ \( vp i\r\);/); + expect(yield* Queue.take(writes)).toContain("vp i"); }), ); @@ -172,7 +176,7 @@ describe("ProjectSetupScriptRunner", () => { Effect.gen(function* () { const { writes } = yield* setup(); yield* TestClock.adjust("3 seconds"); - expect(yield* Queue.take(writes)).toMatch(/^ \( vp i\r\);/); + expect(yield* Queue.take(writes)).toContain("vp i"); }), ); @@ -194,7 +198,7 @@ describe("ProjectSetupScriptRunner", () => { yield* emit({ ...event, type: "restarted", snapshot: openSnapshot("") }); // The restart lands inside a quiet window, so the next full window decides. yield* TestClock.adjust("6 seconds"); - expect(yield* Queue.take(writes)).toMatch(/^ \( vp i\r\);/); + expect(yield* Queue.take(writes)).toContain("vp i"); }), ); @@ -227,3 +231,110 @@ describe("ProjectSetupScriptRunner", () => { }), ); }); + +const runObservedSetup = Effect.fnUntraced(function* (command: string) { + const { writes, outputLines, emit, completion } = yield* setup({ command }); + yield* emit({ ...event, type: "output", data: "\x1b[?2004h" }); + const submitted = yield* Queue.take(writes); + const cwd = yield* HostProcess.WorkingDirectory; + const env = yield* HostProcess.Environment; + + // Enter becomes a newline at the terminal boundary; transport assertions below + // cover line-editor submissions and canonical TTY limits that bash -c bypasses. + const child = yield* Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const handle = yield* spawner.spawn( + ChildProcess.make( + "/bin/bash", + ["--noprofile", "--norc", "-c", submitted.replaceAll("\r", "\n")], + { + cwd, + env: { PATH: env.PATH }, + }, + ), + ); + return yield* Effect.all( + { + stdout: handle.stdout.pipe(Stream.decodeText(), Stream.mkString), + stderr: handle.stderr.pipe(Stream.decodeText(), Stream.mkString), + exitCode: handle.exitCode, + }, + { concurrency: "unbounded" }, + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); + yield* emit({ ...event, type: "output", data: submitted + "\n" }); + yield* emit({ ...event, type: "output", data: child.stdout + child.stderr }); + yield* emit({ ...event, type: "closed" }); + return { + submitted, + lines: yield* Queue.clear(outputLines), + exitCode: (yield* completion).exitCode, + }; +}); + +describe.skipIf(HostProcess.Platform.defaultValue() === "win32")("POSIX setup completion", () => { + it.live("submits one line while preserving multiline shell syntax and literal text", () => + Effect.gen(function* () { + const result = yield* runObservedSetup( + [ + "printf '%s\\n' \"it's ready\" 'literal \\n $(printf wrong) `printf wrong` café'", + "cat <<'END'", + "$literal \\path 雪", + "END", + "printf '%s\\n' done # trailing comment", + ].join("\r\n"), + ); + assert.equal(result.submitted.at(-1), "\r"); + assert.notMatch(result.submitted.slice(0, -1), /[\r\n]/); + assert.deepEqual(result.lines, [ + "it's ready", + "literal \\n $(printf wrong) `printf wrong` café", + "$literal \\path 雪", + "done", + ]); + assert.equal(result.exitCode, 0); + }), + ); + + it.live("completes long scripts without exceeding canonical terminal line limits", () => + Effect.gen(function* () { + const expected = Array.from( + { length: 120 }, + (_, index) => `line-${index}: café 雪 'quoted' \\path`, + ); + const command = expected + .map((line) => `printf '%s\\n' "${line.replaceAll("\\", "\\\\")}"`) + .join("\n"); + const utf8 = new TextEncoder(); + assert.isAbove(utf8.encode(command).byteLength, 4_096); + + const result = yield* runObservedSetup(command); + assert.deepEqual(result.lines, expected); + assert.equal(result.exitCode, 0); + assert.equal(result.submitted.at(-1), "\r"); + assert.notInclude(result.submitted, "\n"); + // The runner can write before the shell switches the terminal out of canonical mode. + for (const line of result.submitted.slice(0, -1).split("\r")) { + assert.isBelow(utf8.encode(line).byteLength, 1_024); + } + }), + ); + + it.live("preserves a trailing backslash-newline even when losing it would still exit zero", () => + Effect.gen(function* () { + const result = yield* runObservedSetup("printf '%s\\n' direct\\\n"); + assert.deepEqual(result.lines, ["direct"]); + assert.equal(result.exitCode, 0); + }), + ); + + it.live.each([ + { name: "an explicit exit", command: "exit 7", exitCode: 7 }, + { name: "a syntax error", command: "if then", exitCode: 2 }, + ])("reports a nonzero completion for $name", ({ command, exitCode }) => + Effect.gen(function* () { + const result = yield* runObservedSetup(command); + assert.equal(result.exitCode, exitCode); + }), + ); +}); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index a049bc89b01a1..7a5940818e0f0 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -177,7 +177,57 @@ function resolveCompletionShell(shell: string): CompletionShell | null { return "posix"; } -// A block with its own closing line protects the sentinel from trailing comments, heredocs and scripts reading stdin. +const utf8 = new TextEncoder(); +// Canonical TTY input (before a line editor starts reading) caps a line below 1 KiB on Linux. +const POSIX_MAX_LINE_BYTES = 1_023; +const POSIX_APPEND = '__t3_command="$__t3_command"'; + +function encodePosixScript(text: string): string { + let encoded = ""; + for (const character of text) { + const code = character.charCodeAt(0); + if (character === "'") { + encoded += "'\\''"; + } else if (character === "\\" || code <= 0x1f || code === 0x7f) { + encoded += `\\0${code.toString(8).padStart(3, "0")}`; + } else { + encoded += character; + } + } + return encoded; +} + +// Line editors run each submitted line on its own, so every line must be a complete command. +function wrapPosixCommand(command: string, sentinel: string): string { + const normalized = command.replace(/\r\n/g, "\n"); + // The `_` suffix keeps trailing newlines; the subshell contains exits and syntax errors. + const evaluate = (payload: string) => + `( __t3_command=$(printf '%b_' ${payload}); eval "\${__t3_command%_}" ); printf '\\n${sentinel}%s\\n' "$?"`; + const inline = evaluate(`'${encodePosixScript(normalized)}'`); + if (utf8.encode(` ${inline}\r`).byteLength <= POSIX_MAX_LINE_BYTES) return inline; + + const maxChunkBytes = POSIX_MAX_LINE_BYTES - utf8.encode(` ${POSIX_APPEND}''\r`).byteLength; + const lines: string[] = []; + let prefix = "__t3_command="; + let chunk = ""; + let chunkBytes = 0; + for (const character of normalized) { + const encoded = encodePosixScript(character); + const bytes = utf8.encode(encoded).byteLength; + if (chunkBytes + bytes > maxChunkBytes) { + lines.push(`${prefix}'${chunk}'`); + prefix = POSIX_APPEND; + chunk = ""; + chunkBytes = 0; + } + chunk += encoded; + chunkBytes += bytes; + } + lines.push(`${prefix}'${chunk}'`, `${evaluate('"$__t3_command"')}; unset __t3_command`); + // The leading space gives every line the history suppression a short command gets. + return lines.join("\r "); +} + function wrapCommandForCompletion( command: string, shell: CompletionShell, @@ -204,7 +254,7 @@ function wrapCommandForCompletion( ].join("; "); } case "posix": - return `( ${body}\r); printf '\\n${sentinel}%s\\n' "$?"`; + return wrapPosixCommand(command, sentinel); } }