Skip to content
Open
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
18 changes: 17 additions & 1 deletion apps/server/src/project/ProjectSetupScriptRunner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const makeHarness = Effect.fn(function* (options: {
readonly history?: string;
readonly opening?: Deferred.Deferred<void>;
readonly shell?: string | null;
readonly command?: string;
}) {
const listeners = new Set<(event: TerminalEvent) => Effect.Effect<void>>();
const writes = yield* Queue.unbounded<string>();
Expand Down Expand Up @@ -88,7 +89,7 @@ const makeHarness = Effect.fn(function* (options: {
{
id: "setup",
name: "Setup",
command: "vp i",
command: options.command ?? "vp i",
icon: "configure",
runOnWorktreeCreate: true,
},
Expand All @@ -104,6 +105,7 @@ const setup = Effect.fn(function* (
options: {
readonly history?: string;
readonly shell?: string | null;
readonly command?: string;
} = {},
) {
const harness = yield* makeHarness(options);
Expand All @@ -123,6 +125,20 @@ describe("ProjectSetupScriptRunner", () => {
}),
);

it.effect("a fish setup command that fails to parse still reports completion", () =>
Effect.gen(function* () {
const command = 'node "${HOME:-.}/x.mjs" setup';
const { writes } = yield* setup({ shell: "/usr/bin/fish", command });
yield* TestClock.adjust("3 seconds");
const [check, ...rest] = (yield* Queue.take(writes)).split("\r");
expect(check).toContain(
`'node "\${HOME:-.}/x.mjs" setup' | '/usr/bin/fish' --no-config --no-execute; or printf`,
);
// Typed on its own line so the fish line editor still expands abbreviations.
expect(rest).toContain(command);
}),
);

it.effect("shows a startup prompt and types only once the line editor starts", () =>
Effect.gen(function* () {
const { result, writes, outputLines, emit } = yield* setup();
Expand Down
17 changes: 16 additions & 1 deletion apps/server/src/project/ProjectSetupScriptRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,18 +176,32 @@ function resolveCompletionShell(shell: string): CompletionShell | null {
return "posix";
}

function quoteFish(value: string): string {
return `'${value.replace(/[\\']/g, "\\$&")}'`;
}

// A block with its own closing line protects the sentinel from trailing comments, heredocs and scripts reading stdin.
// Interactive fish leaves a body that fails to parse in its editor, so it is parse-checked first, except on fish
// before 3.3 (no --no-config) or when it uses an abbreviation, which only the editor expands.
function wrapCommandForCompletion(
command: string,
shell: CompletionShell,
shellPath: string,
sentinel: string,
): string {
const body = command.replace(/\r?\n/g, "\r");
switch (shell) {
case "powershell":
return `$global:LASTEXITCODE = $null; & {\r${body}\r}; if ($null -ne $LASTEXITCODE) { $__t3c = $LASTEXITCODE } elseif ($?) { $__t3c = 0 } else { $__t3c = 1 }; Write-Host "${sentinel}$__t3c"`;
case "fish":
return `begin\r${body}\rend; printf '\\n${sentinel}%s\\n' $status`;
return [
"begin; if string match -qr '^(3\\.([3-9]|[1-9][0-9])|[4-9]|[1-9][0-9])\\.' -- $version",
"and not abbr --show | string match -q -- '* --regex *'",
"set -l a (abbr --list | string escape --style=regex)",
`if test (count $a) -eq 0; or not string match -qr -- '(^|[\\s;|&(])('(string join '|' -- $a)')($|[\\s;|&)])' ${quoteFish(body)}`,
`printf '%s' ${quoteFish(body)} | ${quoteFish(shellPath)} --no-config --no-execute; or printf '\\n${sentinel}%s\\n' $status`,
`end; end; end\rbegin\r${body}\rend; printf '\\n${sentinel}%s\\n' $status`,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High project/ProjectSetupScriptRunner.ts:203

When the fish parse check fails, the wrapper still submits body to the interactive editor, leaving syntax-invalid input in its buffer. Because retries reuse setup-${script.id} and nonzero completions keep the shell open, the next wrapper is inserted into that buffer and can hang without producing a sentinel; skip submitting body after a failed check or clear/recreate the shell before reuse.

🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @apps/server/src/project/ProjectSetupScriptRunner.ts around line 203:

When the fish parse check fails, the wrapper still submits `body` to the interactive editor, leaving syntax-invalid input in its buffer. Because retries reuse `setup-${script.id}` and nonzero completions keep the shell open, the next wrapper is inserted into that buffer and can hang without producing a sentinel; skip submitting `body` after a failed check or clear/recreate the shell before reuse.

].join("; ");
case "posix":
return `( ${body}\r); printf '\\n${sentinel}%s\\n' "$?"`;
}
Expand Down Expand Up @@ -500,6 +514,7 @@ export const make = Effect.gen(function* () {
commandLine = wrapCommandForCompletion(
script.command,
completionShell,
shell,
completionSentinel(completionToken),
);
}
Expand Down
Loading