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
30 changes: 29 additions & 1 deletion apps/server/src/project/ProjectSetupScriptRunner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ const openSnapshot = (history: string): TerminalSessionSnapshot => ({
});

const makeHarness = Effect.fn(function* (options: {
readonly command?: string;
readonly history?: string;
readonly opening?: Deferred.Deferred<void>;
readonly shell?: string | null;
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 @@ -102,6 +103,7 @@ const makeHarness = Effect.fn(function* (options: {

const setup = Effect.fn(function* (
options: {
readonly command?: string;
readonly history?: string;
readonly shell?: string | null;
} = {},
Expand All @@ -123,6 +125,32 @@ describe("ProjectSetupScriptRunner", () => {
}),
);

it.effect("types an escaped Nushell wrapper once its line editor starts", () =>
Effect.gen(function* () {
const command =
'print "quoted"; print \'C:\\tools\'; print "$env.PATH"\nexit 7 # comment\r\n\t';
const escaped =
'print \\"quoted\\"; print \'C:\\\\tools\'; print \\"$env.PATH\\"\\u{a}exit 7 # comment\\u{d}\\u{a}\\u{9}';
const { writes, emit } = yield* setup({ shell: "/opt/homebrew/bin/nu", command });
yield* TestClock.adjust("1 second");
expect(yield* Queue.size(writes)).toBe(0);
yield* emit({ ...event, type: "output", data: "> \x1b[?2004h" });
const written = yield* Queue.take(writes);
const sentinel = written.match(/__T3_SETUP_DONE___[0-9a-f]{32}:/)?.[0];
expect(sentinel).toBeDefined();
expect(written).toBe(
` ${[
"$env.LAST_EXIT_CODE = 0",
"let __t3f = try { mktemp --tmpdir-path $env.T3CODE_WORKTREE_PATH --suffix .nu .t3-setup-XXXXXXXX } catch {|e| print -e $e.msg; null }",
`if $__t3f == null { $env.LAST_EXIT_CODE = 1 } else { try { [$"rm --permanent ($__t3f | to nuon)" "${escaped}"] | str join (char newline) | save --force --raw $__t3f; ^$nu.current-exe --login --commands $"cd $env.T3CODE_WORKTREE_PATH; source ($__t3f | to nuon)" } catch {|e| if $e != null and $e.exit_code? == null { print -e $e.msg }; if $env.LAST_EXIT_CODE == 0 { $env.LAST_EXIT_CODE = 1 } } }`,
"let __t3c = $env.LAST_EXIT_CODE",
"try { if $__t3f != null and ($__t3f | path exists) { rm --permanent $__t3f } } catch {|e| print -e $e.msg }",
`print $"\\n${sentinel}($__t3c)"`,
].join("; ")}\r`,
);
}),
);

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
25 changes: 20 additions & 5 deletions apps/server/src/project/ProjectSetupScriptRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ function stripTerminalControl(text: string): string {
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "");
}

// Line editors (zsh, fish, bash 5.1+) enable bracketed paste when they start reading a command.
// Line editors (zsh, fish, nu, bash 5.1+) enable bracketed paste when they start reading a command.
const BRACKETED_PASTE_ON = "\x1b[?2004h";
const BRACKETED_PASTE_OFF = "\x1b[?2004l";
// Longer than one subprocess poll plus its ps timeout, so a startup prompt is seen as busy first.
Expand All @@ -166,12 +166,13 @@ function lineEditorReading(text: string, previous: boolean): boolean {
return on === off ? previous : on > off;
}

type CompletionShell = "posix" | "fish" | "powershell";
type CompletionShell = "posix" | "fish" | "nushell" | "powershell";

function resolveCompletionShell(shell: string): CompletionShell | null {
const name = (shell.split(/[/\\]/).at(-1) ?? shell).toLowerCase().replace(/\.exe$/, "");
if (name === "cmd") return null;
if (name === "fish") return "fish";
if (name === "nu") return "nushell";
if (name === "pwsh" || name === "powershell") return "powershell";
return "posix";
}
Expand All @@ -188,6 +189,20 @@ function wrapCommandForCompletion(
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`;
case "nushell": {
const escaped = command.replace(/["\\\x00-\x1f]/g, (char) =>
char === '"' || char === "\\" ? `\\${char}` : `\\u{${char.charCodeAt(0).toString(16)}}`,
);
// Nu resolves imports before running cd; source a file rooted in the worktree.
return [
"$env.LAST_EXIT_CODE = 0",
"let __t3f = try { mktemp --tmpdir-path $env.T3CODE_WORKTREE_PATH --suffix .nu .t3-setup-XXXXXXXX } catch {|e| print -e $e.msg; null }",
`if $__t3f == null { $env.LAST_EXIT_CODE = 1 } else { try { [$"rm --permanent ($__t3f | to nuon)" "${escaped}"] | str join (char newline) | save --force --raw $__t3f; ^$nu.current-exe --login --commands $"cd $env.T3CODE_WORKTREE_PATH; source ($__t3f | to nuon)" } catch {|e| if $e != null and $e.exit_code? == null { print -e $e.msg }; if $env.LAST_EXIT_CODE == 0 { $env.LAST_EXIT_CODE = 1 } } }`,
"let __t3c = $env.LAST_EXIT_CODE",
"try { if $__t3f != null and ($__t3f | path exists) { rm --permanent $__t3f } } catch {|e| print -e $e.msg }",
`print $"\\n${sentinel}($__t3c)"`,
].join("; ");
}
case "posix":
return `( ${body}\r); printf '\\n${sentinel}%s\\n' "$?"`;
}
Expand Down Expand Up @@ -517,9 +532,9 @@ export const make = Effect.gen(function* () {
// Return once the terminal exists so callers can show it while a startup prompt waits.
// PowerShell and cmd have no line-editor signal.
yield* (
completionShell === "posix" || completionShell === "fish"
? readiness.wait
: Effect.succeed("ready" as const)
completionShell === "powershell" || completionShell === null
? Effect.succeed("ready" as const)
: readiness.wait
).pipe(
Effect.flatMap((shell) =>
shell === "exited" || commandLine === null
Expand Down
Loading