Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion src/renderer/utils/shellUtils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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 });
Expand All @@ -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>();
Expand Down
13 changes: 11 additions & 2 deletions src/renderer/utils/shellUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand All @@ -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(
Expand Down