From 756d1fec84385d4b0bf180cb2e43462bcc7bc5d5 Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Thu, 30 Jul 2026 01:17:49 -0700 Subject: [PATCH] fix(shell): run setup completion tracking in bash --- src/renderer/utils/shellUtils.test.ts | 29 ++++++++++++++++++++++++++- src/renderer/utils/shellUtils.ts | 13 ++++++++++-- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/src/renderer/utils/shellUtils.test.ts b/src/renderer/utils/shellUtils.test.ts index ed15d6e9..a51fc275 100644 --- a/src/renderer/utils/shellUtils.test.ts +++ b/src/renderer/utils/shellUtils.test.ts @@ -41,6 +41,15 @@ function lastWrite(): string { return bridge.writeTerminal.mock.calls.at(-1)?.[0].data ?? ""; } +function unwrapBashScript(script: string): string { + const prefix = "command bash -c "; + expect(script.startsWith(prefix)).toBe(true); + const quoted = script.slice(prefix.length).replace(/ && exit\r$/u, ""); + expect(quoted.startsWith("'")).toBe(true); + expect(quoted.endsWith("'")).toBe(true); + return quoted.slice(1, -1).replaceAll("'\\''", "'"); +} + describe("appendExitOnSuccess", () => { it("uses `&& exit` for posix shells (exit is a command)", () => { expect(appendExitOnSuccess("npm ci", "posix")).toBe("npm ci && exit"); @@ -256,7 +265,11 @@ describe("writeScriptToShellThenExitOnSuccess", () => { emit({ type: "thread-output", threadId: "shell:1", data: "$ ", outputLength: 2 }); const token = /poracode-shell-complete=([^:]+):/u.exec(lastWrite())?.[1]; expect(token).toBeTruthy(); - expect(lastWrite()).toContain('if [ "$__poracode_setup_exit" -eq 0 ]; then exit; fi'); + expect(lastWrite()).toMatch(/^command bash -c /u); + expect(lastWrite()).toMatch(/ && exit\r$/u); + const innerScript = unwrapBashScript(lastWrite()); + expect(innerScript).toContain("__poracode_setup_exit=$?"); + expect(innerScript).toContain('exit "$__poracode_setup_exit"'); const marker = `\u001B]777;poracode-shell-complete=${token}:1\u0007`; emit({ type: "thread-output", threadId: "shell:1", data: marker, outputLength: marker.length }); @@ -266,6 +279,20 @@ describe("writeScriptToShellThenExitOnSuccess", () => { expect(supervisorHandlers).toHaveLength(1); }); + it("quotes setup scripts before handing completion tracking to bash", () => { + writeScriptToShellThenExitOnSuccess( + "shell:1", + 'printf "it\'s ready"', + "posix", + () => {}, + () => {}, + ); + + emit({ type: "thread-output", threadId: "shell:1", data: "> ", outputLength: 2 }); + + expect(unwrapBashScript(lastWrite())).toContain('printf "it\'s ready"'); + }); + it("reports command completion only once when a successful shell exits", () => { const onExit = vi.fn<(exitCode: number | null) => void>(); const onCommandComplete = vi.fn<(exitCode: number) => void>(); diff --git a/src/renderer/utils/shellUtils.ts b/src/renderer/utils/shellUtils.ts index 261a827d..ce6987a0 100644 --- a/src/renderer/utils/shellUtils.ts +++ b/src/renderer/utils/shellUtils.ts @@ -59,6 +59,10 @@ function shellCompletionMarker(token: string): string { return `\u001B]777;poracode-shell-complete=${token}:`; } +function quotePosixShellArg(value: string): string { + return `'${value.replaceAll("'", "'\\''")}'`; +} + function buildScriptWithCompletion( script: string, locationKind: ProjectLocation["kind"], @@ -84,12 +88,17 @@ function buildScriptWithCompletion( } const exitCode = "__poracode_setup_exit"; - return [ + const bashCommand = [ lines.join(" && "), `${exitCode}=$?`, `printf '\\033]777;poracode-shell-complete=${token}:%s\\007' "$${exitCode}"`, - `if [ "$${exitCode}" -eq 0 ]; then exit; fi`, + `exit "$${exitCode}"`, ].join("; "); + // The interactive POSIX shell may be fish, whose assignment and conditional + // syntax differs from bash. Keep completion bookkeeping in bash, then let + // the outer shell exit only when the child reports success. On failure the + // interactive shell remains open for inspection. + return `command bash -c ${quotePosixShellArg(bashCommand)} && exit`; } export function buildScriptWithExitOnSuccess(