diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 4eb466200bc90..eb94f348e7a50 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -41,6 +41,7 @@ const makeHarness = Effect.fn(function* (options: { readonly history?: string; readonly opening?: Deferred.Deferred; readonly shell?: string | null; + readonly command?: string; }) { const listeners = new Set<(event: TerminalEvent) => Effect.Effect>(); const writes = yield* Queue.unbounded(); @@ -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, }, @@ -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); @@ -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(); diff --git a/apps/server/src/project/ProjectSetupScriptRunner.ts b/apps/server/src/project/ProjectSetupScriptRunner.ts index 8e7c3105b36bc..bc6f978938f14 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.ts @@ -176,10 +176,17 @@ 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"); @@ -187,7 +194,14 @@ function wrapCommandForCompletion( 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`, + ].join("; "); case "posix": return `( ${body}\r); printf '\\n${sentinel}%s\\n' "$?"`; } @@ -500,6 +514,7 @@ export const make = Effect.gen(function* () { commandLine = wrapCommandForCompletion( script.command, completionShell, + shell, completionSentinel(completionToken), ); }