diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..48120c2 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,412 @@ +[CmdletBinding()] +param( + [string]$Version, + [string]$LocalZip, + [string]$Sha256, + [string]$InstallRoot, + [switch]$NoPathUpdate, + [string]$Repo = "Q00/ourocode", + [string]$ReleaseUrl, + [string]$Sha256Url, + [switch]$SkipPrerequisiteCheckForTest +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +function Stop-Install { + param([string]$Message) + [Console]::Error.WriteLine($Message) + exit 1 +} + +function Resolve-ExistingFile { + param( + [string]$Path, + [string]$Description + ) + + if ([string]::IsNullOrWhiteSpace($Path)) { + Stop-Install "$Description path was empty." + } + + $resolved = Resolve-Path -LiteralPath $Path -ErrorAction SilentlyContinue + if (-not $resolved) { + Stop-Install "$Description not found: $Path" + } + + return $resolved.ProviderPath +} + +function Get-DefaultInstallRoot { + if ([string]::IsNullOrWhiteSpace($env:LOCALAPPDATA)) { + Stop-Install "LOCALAPPDATA is not set. Pass -InstallRoot to choose an install directory." + } + + return (Join-Path $env:LOCALAPPDATA "Ourocode") +} + +function Get-VersionFromZipName { + param([string]$ZipPath) + + $name = [System.IO.Path]::GetFileName($ZipPath) + $match = [regex]::Match($name, '^ourocode-v(.+)-windows-x64\.zip$', [System.Text.RegularExpressions.RegexOptions]::IgnoreCase) + if ($match.Success) { + return $match.Groups[1].Value + } + + return $null +} + +function Assert-EscriptAvailable { + param([switch]$SkipForTest) + + if ($SkipForTest) { + Write-Host "==> skipping escript.exe prerequisite check for installer test" + return + } + + $escript = Get-Command escript.exe -ErrorAction SilentlyContinue + if (-not $escript) { + Stop-Install @" +Erlang/OTP runtime was not found: escript.exe is not available on PATH. + +Ourocode is installed as an Erlang escript and needs Erlang/OTP to run. +Install Erlang/OTP from https://www.erlang.org/downloads, open a new PowerShell +session, confirm `Get-Command escript.exe` succeeds, then rerun this installer. +"@ + } +} + +function Get-ReleaseAssetUrl { + param( + [string]$Version, + [string]$Repo, + [string]$ReleaseUrl + ) + + $assetName = "ourocode-v$Version-windows-x64.zip" + if ([string]::IsNullOrWhiteSpace($ReleaseUrl)) { + return "https://github.com/$Repo/releases/download/v$Version/$assetName" + } + + return $ReleaseUrl +} + +function Save-UrlToFile { + param( + [string]$Url, + [string]$Destination + ) + + $client = New-Object System.Net.WebClient + try { + $client.DownloadFile($Url, $Destination) + } + finally { + $client.Dispose() + } +} + +function Read-UrlText { + param([string]$Url) + + $client = New-Object System.Net.WebClient + try { + return $client.DownloadString($Url) + } + finally { + $client.Dispose() + } +} + +function Save-ReleaseZip { + param( + [string]$Version, + [string]$Repo, + [string]$ReleaseUrl, + [string]$DestinationDirectory + ) + + $assetName = "ourocode-v$Version-windows-x64.zip" + $ReleaseUrl = Get-ReleaseAssetUrl -Version $Version -Repo $Repo -ReleaseUrl $ReleaseUrl + $destination = Join-Path $DestinationDirectory $assetName + Write-Host "==> downloading $ReleaseUrl" + Save-UrlToFile -Url $ReleaseUrl -Destination $destination + return $destination +} + +function Get-ExpectedReleaseSha256 { + param( + [string]$Version, + [string]$Repo, + [string]$ReleaseUrl, + [string]$Sha256Url + ) + + if (-not [string]::IsNullOrWhiteSpace($Sha256Url)) { + $checksumUrl = $Sha256Url + } + else { + $checksumUrl = "$(Get-ReleaseAssetUrl -Version $Version -Repo $Repo -ReleaseUrl $ReleaseUrl).sha256" + } + + Write-Host "==> downloading checksum $checksumUrl" + $content = Read-UrlText -Url $checksumUrl + $match = [regex]::Match($content, '(?i)\b[0-9a-f]{64}\b') + if (-not $match.Success) { + Stop-Install "Checksum response did not contain a SHA256 value: $checksumUrl" + } + + return $match.Value +} + +function Get-LocalZipSha256 { + param([string]$ZipPath) + + $checksumPath = "$ZipPath.sha256" + if (-not (Test-Path -LiteralPath $checksumPath -PathType Leaf)) { + Stop-Install "Local zip installs require -Sha256 or a sidecar checksum file at $checksumPath." + } + + $content = Get-Content -LiteralPath $checksumPath -Raw + $match = [regex]::Match($content, '(?i)\b[0-9a-f]{64}\b') + if (-not $match.Success) { + Stop-Install "Checksum file did not contain a SHA256 value: $checksumPath" + } + + return $match.Value +} + +function Assert-ZipHash { + param( + [string]$ZipPath, + [string]$ExpectedSha256 + ) + + if ([string]::IsNullOrWhiteSpace($ExpectedSha256)) { + return + } + + $actual = (Get-FileHash -LiteralPath $ZipPath -Algorithm SHA256).Hash.ToLowerInvariant() + $expected = $ExpectedSha256.Trim().ToLowerInvariant() + if ($actual -ne $expected) { + Stop-Install "SHA256 mismatch for $ZipPath. Expected $expected but got $actual." + } +} + +function Expand-ReleaseZip { + param( + [string]$ZipPath, + [string]$DestinationDirectory + ) + + New-Item -ItemType Directory -Force -Path $DestinationDirectory | Out-Null + Expand-Archive -LiteralPath $ZipPath -DestinationPath $DestinationDirectory -Force + + $entrypoint = Get-ChildItem -LiteralPath $DestinationDirectory -Recurse -Force -File | + Where-Object { $_.Name -ceq "ourocode" } | + Sort-Object { $_.FullName.Length } | + Select-Object -First 1 + + if (-not $entrypoint) { + Stop-Install "Release zip did not contain an 'ourocode' escript." + } + + return $entrypoint.Directory.FullName +} + +function Copy-ReleaseRoot { + param( + [string]$SourceRoot, + [string]$DestinationRoot + ) + + New-Item -ItemType Directory -Force -Path $DestinationRoot | Out-Null + Get-ChildItem -LiteralPath $SourceRoot -Force | ForEach-Object { + Copy-Item -LiteralPath $_.FullName -Destination $DestinationRoot -Recurse -Force + } + + $installedOurocode = Join-Path $DestinationRoot "ourocode" + if (-not (Test-Path -LiteralPath $installedOurocode -PathType Leaf)) { + Stop-Install "Install staging failed: missing $installedOurocode" + } +} + +function Move-StagedInstall { + param( + [string]$StageRoot, + [string]$InstallDirectory + ) + + $parent = Split-Path -Parent $InstallDirectory + New-Item -ItemType Directory -Force -Path $parent | Out-Null + + $backup = $null + if (Test-Path -LiteralPath $InstallDirectory) { + $backup = "$InstallDirectory.previous-$([System.Guid]::NewGuid().ToString('N'))" + Move-Item -LiteralPath $InstallDirectory -Destination $backup + } + + try { + Move-Item -LiteralPath $StageRoot -Destination $InstallDirectory + if ($backup -and (Test-Path -LiteralPath $backup)) { + Remove-Item -LiteralPath $backup -Recurse -Force + } + } + catch { + if ($backup -and (Test-Path -LiteralPath $backup) -and -not (Test-Path -LiteralPath $InstallDirectory)) { + Move-Item -LiteralPath $backup -Destination $InstallDirectory + } + throw + } +} + +function Write-CmdLauncher { + param( + [string]$LauncherPath, + [string]$InstallDirectory + ) + + $installedOurocode = Join-Path $InstallDirectory "ourocode" + $installedTty = Join-Path (Join-Path $InstallDirectory "bin") "ourocode_tty.exe" + $content = @" +@echo off +setlocal +if exist "$installedTty" set "OUROCODE_TTY=$installedTty" +escript.exe "$installedOurocode" %* +exit /b %ERRORLEVEL% +"@ + + $launcherDirectory = Split-Path -Parent $LauncherPath + New-Item -ItemType Directory -Force -Path $launcherDirectory | Out-Null + Set-Content -LiteralPath $LauncherPath -Value $content -Encoding ASCII +} + +function ConvertTo-PathKey { + param([string]$Path) + + if ([string]::IsNullOrWhiteSpace($Path)) { + return "" + } + + $trimmed = $Path.Trim().Trim('"') + try { + $full = [System.IO.Path]::GetFullPath($trimmed) + } + catch { + $full = $trimmed + } + + return $full.TrimEnd('\').ToLowerInvariant() +} + +function Add-UserPathOnce { + param([string]$Directory) + + $current = [Environment]::GetEnvironmentVariable("Path", "User") + $entries = @() + if (-not [string]::IsNullOrWhiteSpace($current)) { + $entries = $current -split ';' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } + } + + $targetKey = ConvertTo-PathKey $Directory + $deduped = New-Object System.Collections.Generic.List[string] + $seen = New-Object 'System.Collections.Generic.HashSet[string]' + + foreach ($entry in $entries) { + $key = ConvertTo-PathKey $entry + if ([string]::IsNullOrWhiteSpace($key)) { + continue + } + if ($key -eq $targetKey) { + continue + } + if ($seen.Add($key)) { + $deduped.Add($entry.Trim()) + } + } + + $deduped.Add($Directory) + $newPath = [string]::Join(';', $deduped) + [Environment]::SetEnvironmentVariable("Path", $newPath, "User") + + if (($env:Path -split ';' | ForEach-Object { ConvertTo-PathKey $_ }) -notcontains $targetKey) { + $env:Path = "$Directory;$env:Path" + } +} + +Assert-EscriptAvailable -SkipForTest:$SkipPrerequisiteCheckForTest + +$tempRoot = Join-Path ([System.IO.Path]::GetTempPath()) "ourocode-install-$([System.Guid]::NewGuid().ToString('N'))" +New-Item -ItemType Directory -Force -Path $tempRoot | Out-Null + +try { + $zipPath = $null + if (-not [string]::IsNullOrWhiteSpace($LocalZip)) { + $zipPath = Resolve-ExistingFile -Path $LocalZip -Description "Local zip" + if ([string]::IsNullOrWhiteSpace($Sha256)) { + $Sha256 = Get-LocalZipSha256 -ZipPath $zipPath + } + } + + if ([string]::IsNullOrWhiteSpace($Version)) { + if ($zipPath) { + $Version = Get-VersionFromZipName -ZipPath $zipPath + } + if ([string]::IsNullOrWhiteSpace($Version) -and -not [string]::IsNullOrWhiteSpace($env:OUROCODE_VERSION)) { + $Version = $env:OUROCODE_VERSION + } + if ([string]::IsNullOrWhiteSpace($Version)) { + $Version = "0.1.13" + } + } + + if (-not $zipPath) { + $zipPath = Save-ReleaseZip -Version $Version -Repo $Repo -ReleaseUrl $ReleaseUrl -DestinationDirectory $tempRoot + if ([string]::IsNullOrWhiteSpace($Sha256)) { + $Sha256 = Get-ExpectedReleaseSha256 -Version $Version -Repo $Repo -ReleaseUrl $ReleaseUrl -Sha256Url $Sha256Url + } + } + + $root = $InstallRoot + if ([string]::IsNullOrWhiteSpace($root)) { + $root = Get-DefaultInstallRoot + } + $root = [System.IO.Path]::GetFullPath($root) + + $installDirectory = Join-Path $root $Version + $launcherDirectory = Join-Path $root "bin" + $launcherPath = Join-Path $launcherDirectory "ourocode.cmd" + $expanded = Join-Path $tempRoot "expanded" + $stage = Join-Path $tempRoot "stage" + + Write-Host "==> ourocode install" + Write-Host "==> version: $Version" + Write-Host "==> zip: $zipPath" + Assert-ZipHash -ZipPath $zipPath -ExpectedSha256 $Sha256 + + $releaseRoot = Expand-ReleaseZip -ZipPath $zipPath -DestinationDirectory $expanded + Copy-ReleaseRoot -SourceRoot $releaseRoot -DestinationRoot $stage + Move-StagedInstall -StageRoot $stage -InstallDirectory $installDirectory + Write-CmdLauncher -LauncherPath $launcherPath -InstallDirectory $installDirectory + + if ($NoPathUpdate) { + Write-Host "==> PATH update skipped (-NoPathUpdate)" + } + else { + Add-UserPathOnce -Directory $launcherDirectory + Write-Host "==> added to user PATH: $launcherDirectory" + Write-Host " Open a new PowerShell or Command Prompt session if 'ourocode' is not found." + } + + Write-Host "" + Write-Host "==> ready" + Write-Host " installed: $installDirectory" + Write-Host " command: $launcherPath" +} +finally { + if (Test-Path -LiteralPath $tempRoot) { + Remove-Item -LiteralPath $tempRoot -Recurse -Force + } +} diff --git a/lib/ourocode/model/cli.ex b/lib/ourocode/model/cli.ex index 58b7bf1..80a6547 100644 --- a/lib/ourocode/model/cli.ex +++ b/lib/ourocode/model/cli.ex @@ -37,6 +37,21 @@ defmodule Ourocode.Model.Cli do end end + @doc false + @spec runner_command( + String.t(), + [String.t()], + {:unix | :win32, atom()}, + (String.t() -> String.t() | nil) + ) :: {String.t(), [String.t()]} + def runner_command(path, args, os_type \\ :os.type(), which \\ &System.find_executable/1) + + def runner_command(path, args, {:win32, _name}, _which), do: {path, args} + + def runner_command(path, args, _os_type, which) when is_function(which, 1) do + {which.("sh") || "/bin/sh", ["-c", ~s(exec "$0" "$@" delay = Keyword.get(opts, :retry_base_delay_ms, @retry_base_delay_ms) - run_with_retry(id, path, args(id, prompt, nil), on_chunk, delay, 1) + run_with_retry(id, path, args(id, prompt, nil), on_chunk, delay, 1, run) end end - defp run_with_retry(id, path, args, on_chunk, delay, attempt) do + defp run_with_retry(id, path, args, on_chunk, delay, attempt, run) do emitted = :counters.new(1, []) counted_chunk = fn chunk -> @@ -67,11 +83,11 @@ defmodule Ourocode.Model.Cli do on_chunk.(chunk) end - case run(id, path, args, counted_chunk) do + case run.(id, path, args, counted_chunk) do {:error, {:exit, _status}} = error -> if :counters.get(emitted, 1) == 0 and attempt < @max_attempts do Process.sleep(delay * Integer.pow(2, attempt - 1)) - run_with_retry(id, path, args, on_chunk, delay, attempt + 1) + run_with_retry(id, path, args, on_chunk, delay, attempt + 1, run) else error end @@ -82,18 +98,15 @@ defmodule Ourocode.Model.Cli do end defp run(id, path, args, on_chunk) do - # Spawn through `sh -c 'exec "$0" "$@" \"#{log_path}\" 2>&1", exe] ++ args, + shell: command, + args: command_args, log_path: log_path } end @spec stop(map() | nil) :: :ok def stop(%{mode: :spawned} = handle) do - case Map.get(handle, :os_pid) do - pid when is_integer(pid) and pid > 0 -> - System.cmd("kill", ["-TERM", Integer.to_string(pid)], stderr_to_stdout: true) - - _none -> - :ok - end + handle + |> Map.get(:os_pid) + |> terminate_os_process() erl_port = Map.get(handle, :port) if is_port(erl_port) and Port.info(erl_port) != nil, do: Port.close(erl_port) @@ -68,4 +64,51 @@ defmodule Ourocode.Runtime.McpDaemon.Process do end def stop(_handle), do: :ok + + defp launch_plan(nil, exe, args, log_path) do + if windows?() do + {exe, args} + else + shell = default_shell() + {shell, shell_args(shell, exe, args, log_path)} + end + end + + defp launch_plan(shell, exe, args, log_path) do + if windows?() and windows_shell?(shell) do + {exe, args} + else + {shell, shell_args(shell, exe, args, log_path)} + end + end + + defp default_shell do + System.find_executable("sh") || "/bin/sh" + end + + defp shell_args(_shell, exe, args, log_path) do + ["-c", "exec \"$0\" \"$@\" >\"#{log_path}\" 2>&1", exe] ++ args + end + + defp terminate_os_process(pid) when is_integer(pid) and pid > 0 do + if windows?() do + command = System.find_executable("taskkill") || "taskkill" + System.cmd(command, ["/PID", Integer.to_string(pid), "/T", "/F"], stderr_to_stdout: true) + else + System.cmd("kill", ["-TERM", Integer.to_string(pid)], stderr_to_stdout: true) + end + + :ok + end + + defp terminate_os_process(_pid), do: :ok + + defp windows_shell?(shell) do + shell + |> Path.basename() + |> String.downcase() + |> then(&(&1 in ["cmd", "cmd.exe"])) + end + + defp windows?, do: match?({:win32, _name}, :os.type()) end diff --git a/lib/ourocode/terminal/tty_driver.ex b/lib/ourocode/terminal/tty_driver.ex index ccf3ae4..9851c0d 100644 --- a/lib/ourocode/terminal/tty_driver.ex +++ b/lib/ourocode/terminal/tty_driver.ex @@ -4,14 +4,23 @@ defmodule Ourocode.Terminal.TtyDriver do """ @poll_ms 500 + @helper_osc_prefix "\e]777;ourocode-" + @helper_osc_resize_prefix "\e]777;ourocode-resize=" + @helper_osc_redraw "\e]777;ourocode-control=redraw\a" + @bracketed_paste_start "\e[200~" + @bracketed_paste_end "\e[201~" @doc "Absolute path of the built tty helper, or nil if it is not present." @spec helper_path() :: String.t() | nil def helper_path do + cwd = File.cwd!() + [ System.get_env("OUROCODE_TTY"), - Path.join(File.cwd!(), "rust/ourocode_ipc/target/release/ourocode_tty"), - Path.join(File.cwd!(), "bin/ourocode_tty") + Path.join(cwd, "bin/ourocode_tty.exe"), + Path.join(cwd, "bin/ourocode_tty"), + Path.join(cwd, "rust/ourocode_ipc/target/release/ourocode_tty.exe"), + Path.join(cwd, "rust/ourocode_ipc/target/release/ourocode_tty") ] |> helper_path() end @@ -30,12 +39,10 @@ defmodule Ourocode.Terminal.TtyDriver do path -> port = - Port.open({:spawn_executable, String.to_charlist(path)}, [ - :binary, - :exit_status, - :nouse_stdio, - :hide - ]) + Port.open( + {:spawn_executable, String.to_charlist(path)}, + port_options(:os.type()) + ) case read_header(port, "") do {:ok, cols, rows, rest} -> @@ -68,11 +75,22 @@ defmodule Ourocode.Terminal.TtyDriver do end @spec next_chunk(port() | nil, non_neg_integer()) :: - {:ok, binary()} | {:file_cache_ready, [String.t()]} | :tick | :eof + {:ok, binary()} + | {:ok, binary(), binary()} + | {:resize, {pos_integer(), pos_integer()}} + | {:resize, {pos_integer(), pos_integer()}, binary()} + | {:control, :redraw} + | {:control, :redraw, binary()} + | {:ignore, binary()} + | {:file_cache_ready, [String.t()]} + | :tick + | :eof def next_chunk(port, poll_ms \\ @poll_ms) do receive do {^port, {:data, data}} when is_binary(data) -> - {:ok, data} + data + |> decode_chunk() + |> next_chunk_reply() {^port, {:exit_status, _status}} -> :eof @@ -99,6 +117,11 @@ defmodule Ourocode.Terminal.TtyDriver do System.get_env("OUROCODE_FORCE_TTY") == "1" or match?({:ok, _}, :io.columns()) end + @doc false + @spec port_options(:os.type()) :: [:binary | :exit_status | :nouse_stdio | :hide] + def port_options({:win32, _}), do: [:binary, :exit_status] + def port_options(_os_type), do: [:binary, :exit_status, :nouse_stdio, :hide] + @doc false def enter_sequence, do: "\e[?1049h\e[?1006h\e[?1003h\e[?25l\e[2J\e[H" @@ -136,6 +159,109 @@ defmodule Ourocode.Terminal.TtyDriver do end end + @doc false + @spec decode_chunk(binary()) :: + {:ok, binary(), binary()} + | {:resize, {pos_integer(), pos_integer()}, binary()} + | {:control, :redraw, binary()} + | {:ignore, binary()} + def decode_chunk(data) when is_binary(data) do + case helper_frame_bounds(data) do + nil -> + {:ok, data, ""} + + {0, frame_size} -> + frame = binary_part(data, 0, frame_size) + rest = binary_part(data, frame_size, byte_size(data) - frame_size) + decode_helper_frame(frame, rest) + + {start, _frame_size} -> + raw = binary_part(data, 0, start) + rest = binary_part(data, start, byte_size(data) - start) + {:ok, raw, rest} + end + end + + defp next_chunk_reply({:ok, data, ""}), do: {:ok, data} + defp next_chunk_reply({:resize, size, ""}), do: {:resize, size} + defp next_chunk_reply({:control, :redraw, ""}), do: {:control, :redraw} + defp next_chunk_reply({:ignore, ""}), do: :tick + defp next_chunk_reply(other), do: other + + defp decode_helper_frame(@helper_osc_redraw, rest), do: {:control, :redraw, rest} + + defp decode_helper_frame(@helper_osc_resize_prefix <> rest = frame, remaining) do + with true <- String.ends_with?(rest, "\a"), + value <- binary_part(rest, 0, byte_size(rest) - 1), + [cols_text, rows_text] <- String.split(value, "x", parts: 2), + {cols, ""} when cols > 0 <- Integer.parse(cols_text), + {rows, ""} when rows > 0 <- Integer.parse(rows_text) do + {:resize, {cols, rows}, remaining} + else + _invalid -> + if helper_control_frame?(frame), do: {:ignore, remaining}, else: {:ok, frame, remaining} + end + end + + defp decode_helper_frame(frame, rest) do + if helper_control_frame?(frame), do: {:ignore, rest}, else: {:ok, frame, rest} + end + + defp helper_control_frame?(data) do + String.starts_with?(data, @helper_osc_prefix) and String.ends_with?(data, "\a") + end + + defp helper_frame_bounds(data), do: helper_frame_bounds(data, 0) + + defp helper_frame_bounds(data, offset) when offset >= byte_size(data), do: nil + + defp helper_frame_bounds(data, offset) do + case next_helper_or_paste(data, offset) do + nil -> + nil + + {:helper, index} -> + case match_from(data, "\a", index) do + {bel_index, 1} -> {index, bel_index - index + 1} + :nomatch -> nil + end + + {:paste, index} -> + paste_content_index = index + byte_size(@bracketed_paste_start) + + case match_from(data, @bracketed_paste_end, paste_content_index) do + {paste_end_index, paste_end_size} -> + helper_frame_bounds(data, paste_end_index + paste_end_size) + + :nomatch -> + nil + end + end + end + + defp next_helper_or_paste(data, offset) do + match = + [ + helper: match_from(data, @helper_osc_prefix, offset), + paste: match_from(data, @bracketed_paste_start, offset) + ] + |> Enum.reject(fn {_kind, match} -> match == :nomatch end) + |> Enum.min_by(fn {_kind, {index, _size}} -> index end, fn -> nil end) + + case match do + nil -> nil + {kind, {index, _size}} -> {kind, index} + end + end + + defp match_from(data, pattern, offset) do + if offset >= byte_size(data) do + :nomatch + else + :binary.match(data, pattern, scope: {offset, byte_size(data) - offset}) + end + end + defp safe_close(port) do if is_port(port) and Port.info(port) != nil, do: Port.close(port) :ok diff --git a/lib/ourocode/terminal/tui_answer_submission.ex b/lib/ourocode/terminal/tui_answer_submission.ex index 8a4ac52..fa2c68c 100644 --- a/lib/ourocode/terminal/tui_answer_submission.ex +++ b/lib/ourocode/terminal/tui_answer_submission.ex @@ -129,5 +129,5 @@ defmodule Ourocode.Terminal.TuiAnswerSubmission do |> Kernel.in(["cancel", "decline", "/cancel"]) end - defp log(output, text), do: IO.puts(output, text) + defp log(output, text), do: IO.puts(output, String.replace_invalid(text, "")) end diff --git a/lib/ourocode/terminal/tui_driver_session.ex b/lib/ourocode/terminal/tui_driver_session.ex index d7b4979..882f460 100644 --- a/lib/ourocode/terminal/tui_driver_session.ex +++ b/lib/ourocode/terminal/tui_driver_session.ex @@ -44,11 +44,39 @@ defmodule Ourocode.Terminal.TuiDriverSession do end @spec next_chunk(pid(), non_neg_integer()) :: - {:ok, binary()} | :tick | :eof + {:ok, binary()} + | {:resize, {pos_integer(), pos_integer()}} + | {:control, :redraw} + | :tick + | :eof def next_chunk(state, poll_ms \\ @poll_ms) when is_pid(state) do case TuiState.take_inbuf(state) do "" -> case TtyDriver.next_chunk(TuiState.port(state), poll_ms) do + {:ok, raw, rest} -> + TuiState.put_inbuf(state, rest) + {:ok, raw} + + {:resize, size, rest} -> + TuiState.put_inbuf(state, rest) + TuiState.put_size(state, size) + {:resize, size} + + {:resize, size} -> + TuiState.put_size(state, size) + {:resize, size} + + {:control, :redraw, rest} -> + TuiState.put_inbuf(state, rest) + {:control, :redraw} + + {:control, :redraw} -> + {:control, :redraw} + + {:ignore, rest} -> + TuiState.put_inbuf(state, rest) + :tick + {:file_cache_ready, files} -> TuiState.put_file_cache(state, files) :tick @@ -58,10 +86,31 @@ defmodule Ourocode.Terminal.TuiDriverSession do end buffered -> - {:ok, buffered} + apply_decoded_chunk(state, TtyDriver.decode_chunk(buffered)) end end + defp apply_decoded_chunk(state, {:ok, raw, rest}) do + TuiState.put_inbuf(state, rest) + {:ok, raw} + end + + defp apply_decoded_chunk(state, {:resize, size, rest}) do + TuiState.put_inbuf(state, rest) + TuiState.put_size(state, size) + {:resize, size} + end + + defp apply_decoded_chunk(state, {:control, :redraw, rest}) do + TuiState.put_inbuf(state, rest) + {:control, :redraw} + end + + defp apply_decoded_chunk(state, {:ignore, rest}) do + TuiState.put_inbuf(state, rest) + :tick + end + @spec refresh_size(pid()) :: {pos_integer(), pos_integer()} def refresh_size(state) when is_pid(state) do size = TtyDriver.size(TuiState.size(state)) diff --git a/lib/ourocode/terminal/tui_input_loop.ex b/lib/ourocode/terminal/tui_input_loop.ex index 1a847eb..7a2b98c 100644 --- a/lib/ourocode/terminal/tui_input_loop.ex +++ b/lib/ourocode/terminal/tui_input_loop.ex @@ -64,7 +64,7 @@ defmodule Ourocode.Terminal.TuiInputLoop do TuiInteraction.capturing?(result, state) and match?(%{key: k} when k in [:enter, :escape], event) and - not slash_submit?(event, state) -> + not command_submit?(event, state) -> TuiInteraction.handle_event(event, result, output, state) draw.() cont.() @@ -121,6 +121,15 @@ defmodule Ourocode.Terminal.TuiInputLoop do :continue -> read_key_loop(result, output, state, callbacks) end + {:resize, {columns, rows}} -> + redraw(callbacks, result, output, state, TuiState.buffer(state), columns, rows) + read_key_loop(result, output, state, callbacks) + + {:control, :redraw} -> + {columns, rows} = TuiState.size(state) + redraw(callbacks, result, output, state, TuiState.buffer(state), columns, rows) + read_key_loop(result, output, state, callbacks) + {:ok, chunk} -> {columns, rows} = TuiDriverSession.refresh_size(state) {events, leftover} = KeyReader.decode(TuiState.take_leftover(state) <> chunk) @@ -149,14 +158,18 @@ defmodule Ourocode.Terminal.TuiInputLoop do }) end - defp slash_submit?(%{key: :enter}, state) do + defp command_submit?(%{key: :enter}, state) do state |> TuiState.buffer() |> String.trim_leading() - |> String.starts_with?("/") + |> command_like?() end - defp slash_submit?(_event, _state), do: false + defp command_submit?(_event, _state), do: false + + defp command_like?("/" <> _rest), do: true + defp command_like?("ooo" <> rest), do: rest == "" or String.match?(rest, ~r/^\s/) + defp command_like?(_line), do: false defp pending_cancel_prefix?(result, state) do buffer = TuiState.buffer(state) diff --git a/lib/ourocode/terminal/tui_interaction.ex b/lib/ourocode/terminal/tui_interaction.ex index 61be16e..884d330 100644 --- a/lib/ourocode/terminal/tui_interaction.ex +++ b/lib/ourocode/terminal/tui_interaction.ex @@ -431,7 +431,7 @@ defmodule Ourocode.Terminal.TuiInteraction do defp accepted_notification(""), do: "accepted - answer captured" defp accepted_notification(label), do: "accepted - " <> label - defp log(output, text), do: IO.puts(output, text) + defp log(output, text), do: IO.puts(output, String.replace_invalid(text, "")) defp clear_captured_activity(output) do StringIO.flush(output) diff --git a/lib/ourocode/terminal/tui_login.ex b/lib/ourocode/terminal/tui_login.ex index 066c757..45a726a 100644 --- a/lib/ourocode/terminal/tui_login.ex +++ b/lib/ourocode/terminal/tui_login.ex @@ -257,11 +257,9 @@ defmodule Ourocode.Terminal.TuiLogin do if TuiEnvironment.test_run?() do {:error, :test_run} else - opener = System.find_executable("open") || System.find_executable("xdg-open") - - case opener do + case open_url_command(url, :os.type(), &System.find_executable/1) do nil -> {:error, :not_found} - command -> system_ok(command, [url]) + {command, args} -> system_ok(command, args) end end rescue @@ -272,15 +270,51 @@ defmodule Ourocode.Terminal.TuiLogin do if TuiEnvironment.test_run?() do {:error, :test_run} else - case clipboard_command() do - nil -> {:error, :not_found} - command -> copy_with_stdin(command, text) + case windows_clipboard_command(text, :os.type(), &System.find_executable/1) do + {command, args, env} -> + system_ok(command, args, env: env) + + nil -> + copy_to_unix_clipboard(text) end end rescue exception -> {:error, exception} end + defp copy_to_unix_clipboard(text) do + case clipboard_command() do + nil -> {:error, :not_found} + command -> copy_with_stdin(command, text) + end + end + + @doc false + @spec open_url_command(String.t(), tuple(), (String.t() -> String.t() | nil)) :: + {String.t(), [String.t()]} | nil + def open_url_command(url, {:win32, _}, _find_executable) do + {"rundll32.exe", ["url.dll,FileProtocolHandler", url]} + end + + def open_url_command(url, _os_type, find_executable) do + case find_executable.("open") || find_executable.("xdg-open") do + nil -> nil + command -> {command, [url]} + end + end + + @doc false + @spec windows_clipboard_command(String.t(), tuple(), (String.t() -> String.t() | nil)) :: + {String.t(), [String.t()], [{String.t(), String.t()}]} | nil + def windows_clipboard_command(text, {:win32, _}, find_executable) do + command = find_executable.("powershell.exe") || "powershell.exe" + args = ["-NoProfile", "-Command", "Set-Clipboard -Value $env:OUROCODE_CLIPBOARD_TEXT"] + + {command, args, [{"OUROCODE_CLIPBOARD_TEXT", text}]} + end + + def windows_clipboard_command(_text, _os_type, _find_executable), do: nil + defp clipboard_command do System.find_executable("pbcopy") || if(File.exists?("/usr/bin/pbcopy"), do: "/usr/bin/pbcopy") diff --git a/rust/ourocode_ipc/Cargo.lock b/rust/ourocode_ipc/Cargo.lock index 9cae354..09e60eb 100644 --- a/rust/ourocode_ipc/Cargo.lock +++ b/rust/ourocode_ipc/Cargo.lock @@ -26,6 +26,7 @@ version = "0.1.0" dependencies = [ "libc", "serde_json", + "windows-sys", ] [[package]] @@ -105,6 +106,79 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "zmij" version = "1.0.21" diff --git a/rust/ourocode_ipc/Cargo.toml b/rust/ourocode_ipc/Cargo.toml index ecf76e8..bf4f4d3 100644 --- a/rust/ourocode_ipc/Cargo.toml +++ b/rust/ourocode_ipc/Cargo.toml @@ -13,6 +13,17 @@ path = "src/lib.rs" name = "ourocode_tty" path = "src/bin/ourocode_tty.rs" +[[bin]] +name = "ourocode" +path = "src/bin/ourocode.rs" + [dependencies] serde_json = "1" libc = "0.2" +windows-sys = { version = "0.59", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_Storage_FileSystem", + "Win32_System_Console", + "Win32_System_IO", +] } diff --git a/rust/ourocode_ipc/src/bin/ourocode.rs b/rust/ourocode_ipc/src/bin/ourocode.rs new file mode 100644 index 0000000..ada38f6 --- /dev/null +++ b/rust/ourocode_ipc/src/bin/ourocode.rs @@ -0,0 +1,240 @@ +use std::env; +use std::ffi::OsString; +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitCode}; + +fn main() -> ExitCode { + match run() { + Ok(code) => ExitCode::from(code), + Err(message) => { + eprintln!("ourocode.exe: {message}"); + ExitCode::from(1) + } + } +} + +fn run() -> Result { + let exe = + env::current_exe().map_err(|error| format!("could not locate executable: {error}"))?; + let exe_dir = exe + .parent() + .ok_or_else(|| format!("could not resolve executable directory: {}", exe.display()))?; + let escript = resolve_escript(exe_dir) + .ok_or_else(|| format!("could not find ourocode escript near {}", exe_dir.display()))?; + let tty = resolve_tty(exe_dir); + let escript_runner = resolve_escript_runner().ok_or_else(|| { + "could not find escript.exe; set ESCRIPT or install Erlang/Elixir".to_owned() + })?; + + let mut command = Command::new(escript_runner); + command.arg(escript).args(env::args_os().skip(1)); + + if env::var_os("OUROCODE_TTY").is_none() { + if let Some(path) = tty { + command.env("OUROCODE_TTY", path); + } + } + + let status = command + .status() + .map_err(|error| format!("failed to start escript.exe: {error}"))?; + Ok(exit_code(status.code())) +} + +fn exit_code(code: Option) -> u8 { + match code { + Some(value) if (0..=255).contains(&value) => value as u8, + Some(_) => 1, + None => 1, + } +} + +fn resolve_escript(exe_dir: &Path) -> Option { + candidate_escripts(exe_dir) + .into_iter() + .find(|candidate| candidate.is_file()) +} + +fn candidate_escripts(exe_dir: &Path) -> [PathBuf; 2] { + [ + exe_dir.join("ourocode"), + exe_dir.parent().map_or_else( + || exe_dir.join("ourocode"), + |parent| parent.join("ourocode"), + ), + ] +} + +fn resolve_escript_runner() -> Option { + if let Some(path) = env::var_os("ESCRIPT") { + return Some(path); + } + + candidate_escript_runners() + .into_iter() + .find(|candidate| candidate.is_file()) + .map(PathBuf::into_os_string) + .or_else(|| Some(OsString::from("escript.exe"))) +} + +fn candidate_escript_runners() -> Vec { + let mut candidates = path_escript_candidates(); + + if let Some(user_profile) = env::var_os("USERPROFILE") { + let otp_root = PathBuf::from(user_profile) + .join(".elixir-install") + .join("installs") + .join("otp"); + candidates.extend(versioned_escript_candidates(&otp_root)); + } + + candidates.extend(program_files_escript_candidates("ProgramFiles")); + candidates.extend(program_files_escript_candidates("ProgramFiles(x86)")); + candidates +} + +fn path_escript_candidates() -> Vec { + env::var_os("PATH") + .map(|path| { + env::split_paths(&path) + .map(|entry| entry.join("escript.exe")) + .collect() + }) + .unwrap_or_default() +} + +fn versioned_escript_candidates(root: &Path) -> Vec { + let mut versions = match fs::read_dir(root) { + Ok(entries) => entries + .filter_map(|entry| entry.ok().map(|entry| entry.path())) + .collect::>(), + Err(_error) => Vec::new(), + }; + + versions.sort(); + versions.reverse(); + + versions + .into_iter() + .flat_map(|version| version_escript_candidates(&version)) + .collect() +} + +fn version_escript_candidates(version: &Path) -> Vec { + let mut candidates = vec![version.join("bin").join("escript.exe")]; + + let mut erts_dirs = match fs::read_dir(version) { + Ok(entries) => entries + .filter_map(|entry| entry.ok().map(|entry| entry.path())) + .filter(|path| { + path.file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("erts-")) + }) + .collect::>(), + Err(_error) => Vec::new(), + }; + + erts_dirs.sort(); + erts_dirs.reverse(); + candidates.extend( + erts_dirs + .into_iter() + .map(|path| path.join("bin").join("escript.exe")), + ); + candidates +} + +fn program_files_escript_candidates(var_name: &str) -> Vec { + env::var_os(var_name) + .map(|program_files| { + vec![ + PathBuf::from(&program_files) + .join("Erlang OTP") + .join("bin") + .join("escript.exe"), + PathBuf::from(program_files) + .join("Erlang OTP") + .join("erts-17.0.2") + .join("bin") + .join("escript.exe"), + ] + }) + .unwrap_or_default() +} + +fn resolve_tty(exe_dir: &Path) -> Option { + candidate_ttys(exe_dir) + .into_iter() + .find(|candidate| candidate.is_file()) +} + +fn candidate_ttys(exe_dir: &Path) -> [PathBuf; 2] { + [ + exe_dir.join("ourocode_tty.exe"), + exe_dir.join("bin").join("ourocode_tty.exe"), + ] +} + +#[cfg(test)] +mod tests { + use super::{candidate_escripts, candidate_ttys, exit_code, versioned_escript_candidates}; + use std::fs; + use std::path::{Path, PathBuf}; + + #[test] + fn resolves_escript_candidates_for_source_and_installed_layouts() { + let candidates = candidate_escripts(Path::new("C:/tools/ourocode/bin")); + + assert_eq!(candidates[0], Path::new("C:/tools/ourocode/bin/ourocode")); + assert_eq!(candidates[1], Path::new("C:/tools/ourocode/ourocode")); + } + + #[test] + fn resolves_tty_candidates_for_source_and_installed_layouts() { + let candidates = candidate_ttys(Path::new("C:/tools/ourocode")); + + assert_eq!( + candidates[0], + Path::new("C:/tools/ourocode/ourocode_tty.exe") + ); + assert_eq!( + candidates[1], + Path::new("C:/tools/ourocode/bin/ourocode_tty.exe") + ); + } + + #[test] + fn normalizes_process_exit_codes() { + assert_eq!(exit_code(Some(0)), 0); + assert_eq!(exit_code(Some(255)), 255); + assert_eq!(exit_code(Some(256)), 1); + assert_eq!(exit_code(None), 1); + } + + #[test] + fn searches_newest_elixir_installed_otp_first() { + let root = test_root("ourocode-launcher-otp"); + let old = root.join("28.0.1"); + let new = root.join("29.0.2"); + fs::create_dir_all(old.join("bin")).expect("create old bin"); + fs::create_dir_all(new.join("bin")).expect("create new bin"); + fs::create_dir_all(new.join("erts-17.0.2").join("bin")).expect("create erts bin"); + + let candidates = versioned_escript_candidates(&root); + + assert_eq!(candidates[0], new.join("bin").join("escript.exe")); + assert_eq!( + candidates[1], + new.join("erts-17.0.2").join("bin").join("escript.exe") + ); + assert_eq!(candidates[2], old.join("bin").join("escript.exe")); + + fs::remove_dir_all(root).expect("remove test root"); + } + + fn test_root(name: &str) -> PathBuf { + std::env::temp_dir().join(format!("{name}-{}", std::process::id())) + } +} diff --git a/rust/ourocode_ipc/src/bin/ourocode_tty.rs b/rust/ourocode_ipc/src/bin/ourocode_tty.rs index f287720..210466e 100644 --- a/rust/ourocode_ipc/src/bin/ourocode_tty.rs +++ b/rust/ourocode_ipc/src/bin/ourocode_tty.rs @@ -6,128 +6,948 @@ //! a Port-spawned child has no controlling terminal so `/dev/tty` is not an //! option either. //! -//! So we use the terminal fds the BEAM already holds. Erlang's -//! `:nouse_stdio` port option leaves fds 0/1/2 inherited from the BEAM (the -//! real terminal) and moves the Erlang<->port protocol to fds 3 and 4. -//! termios works on any tty fd whether or not it is the ctty, so we set raw -//! mode directly on fd 0. +//! On Unix, we use the terminal fds the BEAM already holds. Erlang's +//! `:nouse_stdio` port option leaves fds 0/1/2 inherited from the BEAM +//! (the real terminal) and moves the Erlang<->port protocol to fds 3 and 4. +//! termios works on any tty fd whether or not it is the ctty, so the Unix +//! helper sets raw mode directly on fd 0. //! //! * fd 0 terminal input (keystrokes; raw termios set here) //! * fd 1 terminal output (frames written verbatim) //! * fd 3 protocol in (frames from Elixir) //! * fd 4 protocol out (size header then key byte stream to Elixir) //! +//! On Windows, Port stdio remains the protocol pipe on fd 0/1. Console input +//! and output are acquired separately through CONIN$/CONOUT$ or real console +//! std handles. +//! //! Protocol: first thing written to fd 4 is " \n"; everything //! after is the raw key stream. Restores the original termios on exit. -use std::sync::atomic::{AtomicBool, Ordering}; -use std::{mem, process, ptr, thread}; +#[cfg(unix)] +mod unix { + use std::sync::atomic::{AtomicBool, Ordering}; + use std::{mem, process, ptr, thread}; + + const TTY_IN: libc::c_int = 0; + const TTY_OUT: libc::c_int = 1; + const PROTO_IN: libc::c_int = 3; + const PROTO_OUT: libc::c_int = 4; + + static mut SAVED: Option = None; + static RESTORED: AtomicBool = AtomicBool::new(false); + + unsafe fn restore() { + if RESTORED.swap(true, Ordering::SeqCst) { + return; + } + if let Some(saved) = SAVED { + libc::tcsetattr(TTY_IN, libc::TCSANOW, &saved); + } + let seq = b"\x1b[?25h\x1b[?1049l"; + libc::write(TTY_OUT, seq.as_ptr() as *const libc::c_void, seq.len()); + } -const TTY_IN: libc::c_int = 0; -const TTY_OUT: libc::c_int = 1; -const PROTO_IN: libc::c_int = 3; -const PROTO_OUT: libc::c_int = 4; + extern "C" fn on_signal(_sig: libc::c_int) { + unsafe { restore() }; + process::exit(0); + } -static mut SAVED: Option = None; -static RESTORED: AtomicBool = AtomicBool::new(false); + fn install_signal(sig: libc::c_int) { + unsafe { + let mut sa: libc::sigaction = mem::zeroed(); + sa.sa_sigaction = on_signal as *const () as usize; + libc::sigemptyset(&mut sa.sa_mask); + libc::sigaction(sig, &sa, ptr::null_mut()); + } + } -unsafe fn restore() { - if RESTORED.swap(true, Ordering::SeqCst) { - return; + fn write_all(fd: libc::c_int, buf: &[u8]) -> bool { + let mut off = 0usize; + while off < buf.len() { + let w = unsafe { + libc::write( + fd, + buf.as_ptr().add(off) as *const libc::c_void, + buf.len() - off, + ) + }; + if w <= 0 { + return false; + } + off += w as usize; + } + true } - if let Some(saved) = SAVED { - libc::tcsetattr(TTY_IN, libc::TCSANOW, &saved); + + pub fn run() { + // fd 0 must be a terminal. If not (piped / no tty), bail so Elixir falls + // back to the plain renderer. + if unsafe { libc::isatty(TTY_IN) } != 1 { + process::exit(1); + } + + unsafe { + let mut term: libc::termios = mem::zeroed(); + if libc::tcgetattr(TTY_IN, &mut term) != 0 { + process::exit(1); + } + SAVED = Some(term); + + let mut raw = term; + libc::cfmakeraw(&mut raw); + libc::tcsetattr(TTY_IN, libc::TCSANOW, &raw); + } + + for sig in [libc::SIGTERM, libc::SIGINT, libc::SIGHUP, libc::SIGPIPE] { + install_signal(sig); + } + + let (cols, rows) = unsafe { + let mut ws: libc::winsize = mem::zeroed(); + if libc::ioctl(TTY_OUT, libc::TIOCGWINSZ, &mut ws) == 0 + && ws.ws_col > 0 + && ws.ws_row > 0 + { + (ws.ws_col, ws.ws_row) + } else { + (120u16, 40u16) + } + }; + write_all(PROTO_OUT, format!("{} {}\n", cols, rows).as_bytes()); + + // terminal input -> Elixir + thread::spawn(move || { + let mut buf = [0u8; 4096]; + loop { + let n = + unsafe { libc::read(TTY_IN, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) }; + if n <= 0 || !write_all(PROTO_OUT, &buf[..n as usize]) { + process::exit(0); + } + } + }); + + // Elixir frames -> terminal. EOF means Elixir is done. + let mut buf = [0u8; 16384]; + loop { + let n = + unsafe { libc::read(PROTO_IN, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) }; + if n <= 0 || !write_all(TTY_OUT, &buf[..n as usize]) { + break; + } + } + + unsafe { restore() }; + process::exit(0); } - let seq = b"\x1b[?25h\x1b[?1049l"; - libc::write(TTY_OUT, seq.as_ptr() as *const libc::c_void, seq.len()); } -extern "C" fn on_signal(_sig: libc::c_int) { - unsafe { restore() }; - process::exit(0); +#[cfg(unix)] +fn main() { + unix::run(); } -fn install_signal(sig: libc::c_int) { - unsafe { - let mut sa: libc::sigaction = mem::zeroed(); - sa.sa_sigaction = on_signal as *const () as usize; - libc::sigemptyset(&mut sa.sa_mask); - libc::sigaction(sig, &sa, ptr::null_mut()); - } +#[cfg(windows)] +fn main() { + windows::run(); } -fn write_all(fd: libc::c_int, buf: &[u8]) -> bool { - let mut off = 0usize; - while off < buf.len() { - let w = unsafe { - libc::write( - fd, - buf.as_ptr().add(off) as *const libc::c_void, - buf.len() - off, +#[cfg(not(any(unix, windows)))] +fn main() { + eprintln!("ourocode_tty: unsupported platform; falling back"); + std::process::exit(1); +} + +#[cfg(windows)] +mod windows { + use std::ffi::c_void; + use std::mem::MaybeUninit; + use std::process; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::thread; + + use windows_sys::Win32::Foundation::{ + CloseHandle, GENERIC_READ, GENERIC_WRITE, HANDLE, INVALID_HANDLE_VALUE, + }; + use windows_sys::Win32::Storage::FileSystem::{ + CreateFileW, WriteFile, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, + }; + use windows_sys::Win32::System::Console::{ + AttachConsole, GetConsoleMode, GetConsoleScreenBufferInfo, GetStdHandle, ReadConsoleInputW, + SetConsoleCtrlHandler, SetConsoleMode, ATTACH_PARENT_PROCESS, CONSOLE_SCREEN_BUFFER_INFO, + ENABLE_ECHO_INPUT, ENABLE_LINE_INPUT, ENABLE_MOUSE_INPUT, ENABLE_PROCESSED_INPUT, + ENABLE_VIRTUAL_TERMINAL_INPUT, ENABLE_VIRTUAL_TERMINAL_PROCESSING, ENABLE_WINDOW_INPUT, + FROM_LEFT_1ST_BUTTON_PRESSED, INPUT_RECORD, KEY_EVENT, MOUSE_EVENT, STD_INPUT_HANDLE, + STD_OUTPUT_HANDLE, WINDOW_BUFFER_SIZE_EVENT, + }; + + const PROTO_IN: libc::c_int = 0; + const PROTO_OUT: libc::c_int = 1; + const CONSOLE_READ_WRITE: u32 = GENERIC_READ | GENERIC_WRITE; + + static RESTORED: AtomicBool = AtomicBool::new(false); + static mut INPUT_HANDLE: HANDLE = std::ptr::null_mut(); + static mut OUTPUT_HANDLE: HANDLE = std::ptr::null_mut(); + static mut INPUT_MODE: u32 = 0; + static mut OUTPUT_MODE: u32 = 0; + static mut CLOSE_INPUT_HANDLE: bool = false; + static mut CLOSE_OUTPUT_HANDLE: bool = false; + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + enum ConsoleDevice { + Input, + Output, + } + + #[derive(Clone, Copy, Debug, PartialEq, Eq)] + enum ConsoleHandleCandidate { + NamedConsole { name: &'static str, access: u32 }, + StdHandle(u32), + } + + struct AcquiredConsoleHandle { + handle: HANDLE, + close_on_restore: bool, + } + + enum WindowsConsoleEvent { + Key(WindowsKeyEvent), + Resize(WindowsResizeEvent), + Mouse(WindowsMouseEvent), + } + + struct WindowsKeyEvent { + key_down: bool, + unicode_char: Option, + } + + struct WindowsResizeEvent { + columns: i16, + rows: i16, + } + + struct WindowsMouseEvent { + column: i16, + row: i16, + button: MouseButton, + state: MouseButtonState, + } + + enum MouseButton { + Left, + } + + enum MouseButtonState { + Pressed, + } + + struct ConsoleGuard; + + impl Drop for ConsoleGuard { + fn drop(&mut self) { + restore(); + } + } + + fn translate_windows_console_event(event: WindowsConsoleEvent) -> Option> { + match event { + WindowsConsoleEvent::Key(event) => translate_key_event(event), + WindowsConsoleEvent::Resize(event) => translate_resize_event(event), + WindowsConsoleEvent::Mouse(event) => translate_mouse_event(event), + } + } + + fn input_record_to_windows_console_event(record: &INPUT_RECORD) -> Option { + match u32::from(record.EventType) { + KEY_EVENT => { + // SAFETY: Category 8 - FFI boundary union access. Win32 sets + // EventType to KEY_EVENT only when Event.KeyEvent is the active + // INPUT_RECORD union field; tests construct the same invariant. + let event = unsafe { record.Event.KeyEvent }; + // SAFETY: Category 8 - FFI boundary union access. The `W` + // console APIs document UnicodeChar as the active key char + // representation for KEY_EVENT_RECORD. + let unicode = unsafe { event.uChar.UnicodeChar }; + let unicode_char = if unicode == 0 { + None + } else { + char::from_u32(u32::from(unicode)) + }; + + Some(WindowsConsoleEvent::Key(WindowsKeyEvent { + key_down: event.bKeyDown != 0, + unicode_char, + })) + } + WINDOW_BUFFER_SIZE_EVENT => { + // SAFETY: Category 8 - FFI boundary union access. Win32 sets + // EventType to WINDOW_BUFFER_SIZE_EVENT only when + // Event.WindowBufferSizeEvent is the active union field. + let event = unsafe { record.Event.WindowBufferSizeEvent }; + + Some(WindowsConsoleEvent::Resize(WindowsResizeEvent { + columns: event.dwSize.X, + rows: event.dwSize.Y, + })) + } + MOUSE_EVENT => { + // SAFETY: Category 8 - FFI boundary union access. Win32 sets + // EventType to MOUSE_EVENT only when Event.MouseEvent is the + // active INPUT_RECORD union field. + let event = unsafe { record.Event.MouseEvent }; + if event.dwButtonState & FROM_LEFT_1ST_BUTTON_PRESSED == 0 { + return None; + } + + Some(WindowsConsoleEvent::Mouse(WindowsMouseEvent { + column: event.dwMousePosition.X, + row: event.dwMousePosition.Y, + button: MouseButton::Left, + state: MouseButtonState::Pressed, + })) + } + _ => None, + } + } + + fn translate_key_event(event: WindowsKeyEvent) -> Option> { + if !event.key_down { + return None; + } + + event.unicode_char.map(|ch| { + let mut encoded = [0u8; 4]; + ch.encode_utf8(&mut encoded).as_bytes().to_vec() + }) + } + + fn translate_resize_event(event: WindowsResizeEvent) -> Option> { + if event.columns <= 0 || event.rows <= 0 { + return None; + } + + Some( + format!( + "\x1b]777;ourocode-resize={}x{}\x07", + event.columns, event.rows ) + .into_bytes(), + ) + } + + fn translate_mouse_event(event: WindowsMouseEvent) -> Option> { + if event.column < 0 || event.row < 0 { + return None; + } + + let button_code = match event.button { + MouseButton::Left => 0, }; - if w <= 0 { - return false; + let suffix = match event.state { + MouseButtonState::Pressed => 'M', + }; + let column = i32::from(event.column) + 1; + let row = i32::from(event.row) + 1; + + Some(format!("\x1b[<{button_code};{column};{row}{suffix}").into_bytes()) + } + + fn restore() { + if RESTORED.swap(true, Ordering::SeqCst) { + return; + } + + // SAFETY: Category 8 - FFI boundary. The handles and modes are copied + // from successful GetStdHandle/GetConsoleMode calls during setup and + // never mutated after the control handler is installed. + unsafe { + if !INPUT_HANDLE.is_null() && INPUT_HANDLE != INVALID_HANDLE_VALUE { + SetConsoleMode(INPUT_HANDLE, INPUT_MODE); + } + if !OUTPUT_HANDLE.is_null() && OUTPUT_HANDLE != INVALID_HANDLE_VALUE { + SetConsoleMode(OUTPUT_HANDLE, OUTPUT_MODE); + } + + let _ = write_console(b"\x1b[?25h\x1b[?1049l"); + + if CLOSE_INPUT_HANDLE && !INPUT_HANDLE.is_null() && INPUT_HANDLE != INVALID_HANDLE_VALUE + { + CloseHandle(INPUT_HANDLE); + CLOSE_INPUT_HANDLE = false; + } + if CLOSE_OUTPUT_HANDLE + && !OUTPUT_HANDLE.is_null() + && OUTPUT_HANDLE != INVALID_HANDLE_VALUE + { + CloseHandle(OUTPUT_HANDLE); + CLOSE_OUTPUT_HANDLE = false; + } } - off += w as usize; } - true -} -fn main() { - // fd 0 must be a terminal. If not (piped / no tty), bail so Elixir falls - // back to the plain renderer. - if unsafe { libc::isatty(TTY_IN) } != 1 { - process::exit(1); + unsafe extern "system" fn on_console_ctrl(_ctrl_type: u32) -> i32 { + restore(); + 0 } - unsafe { - let mut term: libc::termios = mem::zeroed(); - if libc::tcgetattr(TTY_IN, &mut term) != 0 { - process::exit(1); + fn exit_after_restore(code: i32) -> ! { + restore(); + process::exit(code); + } + + fn write_proto(buf: &[u8]) -> bool { + let mut off = 0usize; + while off < buf.len() { + let remaining = buf.len() - off; + let chunk = remaining.min(i32::MAX as usize); + let written = unsafe { + libc::write( + PROTO_OUT, + buf.as_ptr().add(off) as *const c_void, + chunk as libc::c_uint, + ) + }; + if written <= 0 { + return false; + } + off += written as usize; } - SAVED = Some(term); + true + } - let mut raw = term; - libc::cfmakeraw(&mut raw); - libc::tcsetattr(TTY_IN, libc::TCSANOW, &raw); + fn read_proto(buf: &mut [u8]) -> isize { + (unsafe { + libc::read( + PROTO_IN, + buf.as_mut_ptr() as *mut c_void, + buf.len() as libc::c_uint, + ) + }) as isize } - for sig in [libc::SIGTERM, libc::SIGINT, libc::SIGHUP, libc::SIGPIPE] { - install_signal(sig); + fn write_console(buf: &[u8]) -> bool { + let handle = unsafe { OUTPUT_HANDLE }; + if handle.is_null() || handle == INVALID_HANDLE_VALUE { + return false; + } + + let mut off = 0usize; + while off < buf.len() { + let chunk = (buf.len() - off).min(u32::MAX as usize); + let mut written = 0u32; + let ok = unsafe { + WriteFile( + handle, + buf.as_ptr().add(off), + chunk as u32, + &mut written, + std::ptr::null_mut(), + ) + }; + if ok == 0 || written == 0 { + return false; + } + off += written as usize; + } + true } - let (cols, rows) = unsafe { - let mut ws: libc::winsize = mem::zeroed(); - if libc::ioctl(TTY_OUT, libc::TIOCGWINSZ, &mut ws) == 0 && ws.ws_col > 0 && ws.ws_row > 0 { - (ws.ws_col, ws.ws_row) + fn read_console_event() -> Result, ()> { + let mut record = MaybeUninit::::uninit(); + // SAFETY: Category 8 - FFI boundary. INPUT_HANDLE is written once from + // GetStdHandle during setup before the input thread starts, then only + // read by this function and restore/control-handler code. + let handle = unsafe { INPUT_HANDLE }; + if handle.is_null() || handle == INVALID_HANDLE_VALUE { + return Err(()); + } + + let mut read = 0u32; + // SAFETY: Category 8 - FFI boundary. `record` points to writable + // storage for one INPUT_RECORD, `read` points to writable u32 storage, + // and `handle` was validated against null/INVALID_HANDLE_VALUE above. + let ok = unsafe { ReadConsoleInputW(handle, record.as_mut_ptr(), 1, &mut read) }; + if ok == 0 { + Err(()) + } else if read == 0 { + Ok(None) } else { - (120u16, 40u16) + // SAFETY: Category 4 - uninitialized memory. ReadConsoleInputW + // returned success and reported one record read, so it initialized + // the INPUT_RECORD storage before this assume_init. + let record = unsafe { record.assume_init() }; + Ok(input_record_to_windows_console_event(&record)) } - }; - write_all(PROTO_OUT, format!("{} {}\n", cols, rows).as_bytes()); + } - // terminal input -> Elixir - thread::spawn(move || { - let mut buf = [0u8; 4096]; - loop { - let n = - unsafe { libc::read(TTY_IN, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) }; - if n <= 0 || !write_all(PROTO_OUT, &buf[..n as usize]) { - process::exit(0); + fn terminal_size() -> (i16, i16) { + unsafe { + let mut info: CONSOLE_SCREEN_BUFFER_INFO = std::mem::zeroed(); + if GetConsoleScreenBufferInfo(OUTPUT_HANDLE, &mut info) != 0 { + let cols = info.srWindow.Right - info.srWindow.Left + 1; + let rows = info.srWindow.Bottom - info.srWindow.Top + 1; + if cols > 0 && rows > 0 { + return (cols, rows); + } + } + } + + (120, 40) + } + + fn wide_null(value: &str) -> Vec { + value.encode_utf16().chain(std::iter::once(0)).collect() + } + + const fn console_handle_candidates(device: ConsoleDevice) -> [ConsoleHandleCandidate; 2] { + match device { + ConsoleDevice::Input => [ + ConsoleHandleCandidate::NamedConsole { + name: "CONIN$", + access: CONSOLE_READ_WRITE, + }, + ConsoleHandleCandidate::StdHandle(STD_INPUT_HANDLE), + ], + ConsoleDevice::Output => [ + ConsoleHandleCandidate::NamedConsole { + name: "CONOUT$", + access: CONSOLE_READ_WRITE, + }, + ConsoleHandleCandidate::StdHandle(STD_OUTPUT_HANDLE), + ], + } + } + + fn acquire_console_handle(device: ConsoleDevice) -> Option { + for candidate in console_handle_candidates(device) { + if let Some(handle) = acquire_console_handle_candidate(candidate) { + return Some(handle); + } + } + + None + } + + fn acquire_console_handle_candidate( + candidate: ConsoleHandleCandidate, + ) -> Option { + let (handle, close_on_restore) = match candidate { + ConsoleHandleCandidate::NamedConsole { name, access } => { + let name = wide_null(name); + // SAFETY: Category 8 - FFI boundary. The UTF-16 name buffer is + // null-terminated and lives for the duration of CreateFileW; + // remaining pointers are null because no security attributes, + // template file, or overlapped I/O are requested. + let handle = unsafe { + CreateFileW( + name.as_ptr(), + access, + FILE_SHARE_READ | FILE_SHARE_WRITE, + std::ptr::null_mut(), + OPEN_EXISTING, + 0, + std::ptr::null_mut(), + ) + }; + (handle, true) } + ConsoleHandleCandidate::StdHandle(kind) => { + // SAFETY: Category 8 - FFI boundary. `kind` is one of the + // Win32 STD_* constants produced by console_handle_candidates. + let handle = unsafe { GetStdHandle(kind) }; + (handle, false) + } + }; + + if handle.is_null() || handle == INVALID_HANDLE_VALUE { + return None; } - }); - // Elixir frames -> terminal. EOF means Elixir is done. - let mut buf = [0u8; 16384]; - loop { - let n = unsafe { libc::read(PROTO_IN, buf.as_mut_ptr() as *mut libc::c_void, buf.len()) }; - if n <= 0 || !write_all(TTY_OUT, &buf[..n as usize]) { - break; + let mut mode = 0u32; + // SAFETY: Category 8 - FFI boundary. `handle` was checked against null + // and INVALID_HANDLE_VALUE, and `mode` points to writable stack storage. + if unsafe { GetConsoleMode(handle, &mut mode) } == 0 { + if close_on_restore { + // SAFETY: Category 8 - FFI boundary. This closes only handles + // opened by CreateFileW in this function and not shared std handles. + unsafe { + CloseHandle(handle); + } + } + return None; + } + + Some(AcquiredConsoleHandle { + handle, + close_on_restore, + }) + } + + fn setup_console() -> Option { + // SAFETY: Category 8 - FFI boundary. Each Win32 call receives either + // valid output pointers to stack locals or acquired console handles + // checked before mode calls; saved modes are restored on every failure + // path after mutation. + unsafe { + // SAFETY: Category 8 - FFI boundary. Attaching to the parent console + // is best-effort; it fails harmlessly when the process is already + // attached, and the subsequent CONIN$/CONOUT$ probes decide support. + let _ = AttachConsole(ATTACH_PARENT_PROCESS); + + let input_handle = acquire_console_handle(ConsoleDevice::Input)?; + let output_handle = acquire_console_handle(ConsoleDevice::Output)?; + INPUT_HANDLE = input_handle.handle; + OUTPUT_HANDLE = output_handle.handle; + CLOSE_INPUT_HANDLE = input_handle.close_on_restore; + CLOSE_OUTPUT_HANDLE = output_handle.close_on_restore; + + if INPUT_HANDLE.is_null() || OUTPUT_HANDLE.is_null() { + return None; + } + + let mut input_mode = 0u32; + let mut output_mode = 0u32; + if GetConsoleMode(INPUT_HANDLE, &mut input_mode) == 0 { + return None; + } + if GetConsoleMode(OUTPUT_HANDLE, &mut output_mode) == 0 { + return None; + } + + INPUT_MODE = input_mode; + OUTPUT_MODE = output_mode; + + let raw_input = (input_mode + & !(ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT | ENABLE_PROCESSED_INPUT)) + | ENABLE_MOUSE_INPUT + | ENABLE_WINDOW_INPUT + | ENABLE_VIRTUAL_TERMINAL_INPUT; + let vt_output = output_mode | ENABLE_VIRTUAL_TERMINAL_PROCESSING; + + if SetConsoleMode(INPUT_HANDLE, raw_input) == 0 { + return None; + } + if SetConsoleMode(OUTPUT_HANDLE, vt_output) == 0 { + SetConsoleMode(INPUT_HANDLE, INPUT_MODE); + return None; + } + // SAFETY: Category 8 - FFI boundary. `on_console_ctrl` has the + // PHANDLER_ROUTINE ABI/signature required by SetConsoleCtrlHandler + // and remains valid for the process lifetime. + if SetConsoleCtrlHandler(Some(on_console_ctrl), 1) == 0 { + SetConsoleMode(OUTPUT_HANDLE, OUTPUT_MODE); + SetConsoleMode(INPUT_HANDLE, INPUT_MODE); + return None; + } + } + + Some(ConsoleGuard) + } + + pub fn run() { + let _guard = match setup_console() { + Some(guard) => guard, + None => process::exit(1), + }; + + let (cols, rows) = terminal_size(); + if !write_proto(format!("{} {}\n", cols, rows).as_bytes()) { + exit_after_restore(1); + } + + thread::spawn(move || loop { + match read_console_event() { + Ok(Some(event)) => { + if let Some(buf) = translate_windows_console_event(event) { + if !write_proto(&buf) { + exit_after_restore(0); + } + } + } + Ok(None) => {} + Err(()) => exit_after_restore(0), + } + }); + + let mut buf = [0u8; 16384]; + loop { + let n = read_proto(&mut buf); + if n <= 0 || !write_console(&buf[..n as usize]) { + break; + } } + + exit_after_restore(0); } - unsafe { restore() }; - process::exit(0); + #[cfg(test)] + mod windows_tty_tests { + use super::{ + console_handle_candidates, input_record_to_windows_console_event, + translate_windows_console_event, ConsoleDevice, ConsoleHandleCandidate, MouseButton, + MouseButtonState, WindowsConsoleEvent, WindowsKeyEvent, WindowsMouseEvent, + WindowsResizeEvent, PROTO_IN, PROTO_OUT, + }; + use windows_sys::Win32::Foundation::BOOL; + use windows_sys::Win32::System::Console::{ + COORD, FOCUS_EVENT, FOCUS_EVENT_RECORD, FROM_LEFT_1ST_BUTTON_PRESSED, INPUT_RECORD, + INPUT_RECORD_0, KEY_EVENT, KEY_EVENT_RECORD, KEY_EVENT_RECORD_0, MOUSE_EVENT, + MOUSE_EVENT_RECORD, WINDOW_BUFFER_SIZE_EVENT, WINDOW_BUFFER_SIZE_RECORD, + }; + use windows_sys::Win32::System::Console::{STD_INPUT_HANDLE, STD_OUTPUT_HANDLE}; + + #[test] + fn windows_tty_uses_port_stdio_for_protocol() { + // Given: Elixir opens the Windows helper without :nouse_stdio. + // When: the helper chooses protocol file descriptors. + // Then: fd 0/1 remain the Port protocol pipe instead of fd 3/4. + assert_eq!(PROTO_IN, 0); + assert_eq!(PROTO_OUT, 1); + } + + #[test] + fn windows_tty_prefers_named_console_handles_before_std_handles() { + // Given: Port stdio is reserved for the BEAM protocol pipe. + let input_candidates = console_handle_candidates(ConsoleDevice::Input); + let output_candidates = console_handle_candidates(ConsoleDevice::Output); + + // When: the helper looks for real console I/O handles. + // Then: CONIN$/CONOUT$ are tried first, with std handles only as fallbacks. + assert_eq!( + input_candidates, + [ + ConsoleHandleCandidate::NamedConsole { + name: "CONIN$", + access: super::CONSOLE_READ_WRITE + }, + ConsoleHandleCandidate::StdHandle(STD_INPUT_HANDLE) + ] + ); + assert_eq!( + output_candidates, + [ + ConsoleHandleCandidate::NamedConsole { + name: "CONOUT$", + access: super::CONSOLE_READ_WRITE + }, + ConsoleHandleCandidate::StdHandle(STD_OUTPUT_HANDLE) + ] + ); + } + + #[test] + fn windows_tty_translates_printable_key_when_key_down() { + // Given: a Windows key-down event carrying a printable Unicode scalar. + let event = WindowsConsoleEvent::Key(WindowsKeyEvent { + key_down: true, + unicode_char: Some('A'), + }); + + // When: the pure Windows console event translator handles the event. + let translated = translate_windows_console_event(event); + + // Then: the helper emits the UTF-8 key byte stream expected by Elixir. + assert_eq!(translated.as_deref(), Some(&b"A"[..])); + } + + #[test] + fn windows_tty_translates_control_key_when_key_down() { + // Given: a Windows key-down event carrying a control character. + let event = WindowsConsoleEvent::Key(WindowsKeyEvent { + key_down: true, + unicode_char: Some('\u{3}'), + }); + + // When: the pure Windows console event translator handles the event. + let translated = translate_windows_console_event(event); + + // Then: the helper preserves the control byte rather than dropping it. + assert_eq!(translated.as_deref(), Some(&[0x03][..])); + } + + #[test] + fn windows_tty_ignores_key_up_and_malformed_empty_key_events() { + // Given: key events that should not produce terminal input. + let key_up = WindowsConsoleEvent::Key(WindowsKeyEvent { + key_down: false, + unicode_char: Some('A'), + }); + let empty_key = WindowsConsoleEvent::Key(WindowsKeyEvent { + key_down: true, + unicode_char: None, + }); + + // When: the pure Windows console event translator handles the events. + let translated_key_up = translate_windows_console_event(key_up); + let translated_empty_key = translate_windows_console_event(empty_key); + + // Then: no raw bytes are emitted for malformed or non-down key events. + assert_eq!(translated_key_up, None); + assert_eq!(translated_empty_key, None); + } + + #[test] + fn windows_tty_translates_resize_to_helper_control_frame() { + // Given: a valid Windows buffer resize event. + let event = WindowsConsoleEvent::Resize(WindowsResizeEvent { + columns: 132, + rows: 41, + }); + + // When: the pure Windows console event translator handles the event. + let translated = translate_windows_console_event(event); + + // Then: the helper emits a structured resize control frame for Elixir. + assert_eq!( + translated.as_deref(), + Some(&b"\x1b]777;ourocode-resize=132x41\x07"[..]) + ); + } + + #[test] + fn windows_tty_rejects_malformed_resize_events() { + // Given: malformed resize events from an invalid console state. + let zero_columns = WindowsConsoleEvent::Resize(WindowsResizeEvent { + columns: 0, + rows: 41, + }); + let zero_rows = WindowsConsoleEvent::Resize(WindowsResizeEvent { + columns: 132, + rows: 0, + }); + + // When: the pure Windows console event translator handles the events. + let translated_zero_columns = translate_windows_console_event(zero_columns); + let translated_zero_rows = translate_windows_console_event(zero_rows); + + // Then: invalid dimensions do not produce helper control data. + assert_eq!(translated_zero_columns, None); + assert_eq!(translated_zero_rows, None); + } + + #[test] + fn windows_tty_translates_mouse_press_to_sgr_mouse_sequence() { + // Given: a left-button press at Windows 0-based console coordinates. + let event = WindowsConsoleEvent::Mouse(WindowsMouseEvent { + column: 4, + row: 9, + button: MouseButton::Left, + state: MouseButtonState::Pressed, + }); + + // When: the pure Windows console event translator handles the event. + let translated = translate_windows_console_event(event); + + // Then: the helper emits an SGR mouse sequence with 1-based coordinates. + assert_eq!(translated.as_deref(), Some(&b"\x1b[<0;5;10M"[..])); + } + + #[test] + fn windows_tty_rejects_malformed_mouse_events() { + // Given: a mouse event with coordinates outside the SGR encoding range. + let event = WindowsConsoleEvent::Mouse(WindowsMouseEvent { + column: -1, + row: 9, + button: MouseButton::Left, + state: MouseButtonState::Pressed, + }); + + // When: the pure Windows console event translator handles the event. + let translated = translate_windows_console_event(event); + + // Then: invalid coordinates do not produce raw terminal bytes. + assert_eq!(translated, None); + } + + #[test] + fn windows_tty_converts_raw_key_input_record() { + // Given: a raw Win32 key-down input record carrying a printable Unicode scalar. + let record = INPUT_RECORD { + EventType: KEY_EVENT as u16, + Event: INPUT_RECORD_0 { + KeyEvent: KEY_EVENT_RECORD { + bKeyDown: 1 as BOOL, + wRepeatCount: 1, + wVirtualKeyCode: 0, + wVirtualScanCode: 0, + uChar: KEY_EVENT_RECORD_0 { + UnicodeChar: 'Z' as u16, + }, + dwControlKeyState: 0, + }, + }, + }; + + // When: the helper converts the raw Win32 record through the pure seam. + let translated = input_record_to_windows_console_event(&record) + .and_then(translate_windows_console_event); + + // Then: the same UTF-8 key stream reaches Elixir as with the synthetic event. + assert_eq!(translated.as_deref(), Some(&b"Z"[..])); + } + + #[test] + fn windows_tty_converts_raw_resize_input_record() { + // Given: a raw Win32 window-buffer-size event. + let record = INPUT_RECORD { + EventType: WINDOW_BUFFER_SIZE_EVENT as u16, + Event: INPUT_RECORD_0 { + WindowBufferSizeEvent: WINDOW_BUFFER_SIZE_RECORD { + dwSize: COORD { X: 144, Y: 36 }, + }, + }, + }; + + // When: the helper converts the raw Win32 record through the pure seam. + let translated = input_record_to_windows_console_event(&record) + .and_then(translate_windows_console_event); + + // Then: the existing resize helper protocol is preserved exactly. + assert_eq!( + translated.as_deref(), + Some(&b"\x1b]777;ourocode-resize=144x36\x07"[..]) + ); + } + + #[test] + fn windows_tty_converts_raw_mouse_input_record() { + // Given: a raw Win32 left-button mouse press at 0-based console coordinates. + let record = INPUT_RECORD { + EventType: MOUSE_EVENT as u16, + Event: INPUT_RECORD_0 { + MouseEvent: MOUSE_EVENT_RECORD { + dwMousePosition: COORD { X: 2, Y: 6 }, + dwButtonState: FROM_LEFT_1ST_BUTTON_PRESSED, + dwControlKeyState: 0, + dwEventFlags: 0, + }, + }, + }; + + // When: the helper converts the raw Win32 record through the pure seam. + let translated = input_record_to_windows_console_event(&record) + .and_then(translate_windows_console_event); + + // Then: SGR mouse translation remains intact. + assert_eq!(translated.as_deref(), Some(&b"\x1b[<0;3;7M"[..])); + } + + #[test] + fn windows_tty_ignores_raw_non_input_record() { + // Given: a raw Win32 focus event that carries no terminal input for Elixir. + let record = INPUT_RECORD { + EventType: FOCUS_EVENT as u16, + Event: INPUT_RECORD_0 { + FocusEvent: FOCUS_EVENT_RECORD { bSetFocus: 1 }, + }, + }; + + // When: the helper converts the raw Win32 record through the pure seam. + let converted = input_record_to_windows_console_event(&record); + + // Then: unsupported records are ignored instead of becoming raw bytes. + assert!(converted.is_none()); + } + } } diff --git a/scripts/package-windows.ps1 b/scripts/package-windows.ps1 new file mode 100644 index 0000000..27ad9b9 --- /dev/null +++ b/scripts/package-windows.ps1 @@ -0,0 +1,282 @@ +[CmdletBinding()] +param( + [string]$Version, + [string]$OutputDir = "dist", + [string]$PrebuiltRoot, + [string]$Configuration = "Release" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$RepoRoot = Split-Path -Parent (Split-Path -Parent $PSCommandPath) +$WindowsTarget = "x86_64-pc-windows-msvc" + +function Write-Step { + param([string]$Message) + Write-Host "==> $Message" +} + +function Resolve-RepoPath { + param([string]$Path) + if ([System.IO.Path]::IsPathRooted($Path)) { + return $Path + } + return (Join-Path $RepoRoot $Path) +} + +function Get-ProjectVersion { + $MixFile = Join-Path $RepoRoot "mix.exs" + if (-not (Test-Path -LiteralPath $MixFile)) { + throw "Version was not provided and mix.exs was not found." + } + + $Match = Select-String -Path $MixFile -Pattern 'version:\s*"([^"]+)"' | Select-Object -First 1 + if ($null -eq $Match) { + throw "Version was not provided and no version entry was found in mix.exs." + } + + return $Match.Matches[0].Groups[1].Value +} + +function Require-Command { + param( + [string]$Name, + [string]$InstallHint + ) + + $Command = Get-Command $Name -ErrorAction SilentlyContinue + if ($null -eq $Command) { + throw "Missing prerequisite '$Name'. $InstallHint" + } + + Write-Host (" {0}: {1}" -f $Name, $Command.Source) +} + +function Invoke-Checked { + param( + [string]$FilePath, + [string[]]$Arguments + ) + + & $FilePath @Arguments + if ($LASTEXITCODE -ne 0) { + throw "Command failed with exit code ${LASTEXITCODE}: $FilePath $($Arguments -join ' ')" + } +} + +function Assert-NormalBuildPrerequisites { + Write-Step "checking Windows release build prerequisites" + Require-Command "git" "Install Git for Windows and open a new PowerShell session." + Require-Command "erl" "Install Erlang/OTP and ensure erl.exe is on PATH." + Require-Command "elixir" "Install Elixir and ensure elixir.exe is on PATH." + Require-Command "mix" "Install Elixir/Mix and ensure mix.bat is on PATH." + Require-Command "cargo" "Install the Rust stable MSVC toolchain and ensure cargo.exe is on PATH." + Require-Command "rustc" "Install the Rust stable MSVC toolchain and ensure rustc.exe is on PATH." + + $RustInfo = & rustc -vV + if ($LASTEXITCODE -ne 0) { + throw "rustc -vV failed with exit code $LASTEXITCODE." + } + + $HostLine = $RustInfo | Where-Object { $_ -like "host:*" } | Select-Object -First 1 + if ($HostLine -notlike "*$WindowsTarget*") { + throw "Rust host toolchain must be $WindowsTarget for this package script. Current ${HostLine}. Install with: rustup toolchain install stable-$WindowsTarget" + } + + $Rustup = Get-Command "rustup" -ErrorAction SilentlyContinue + if ($null -ne $Rustup) { + $InstalledTargets = & rustup target list --installed + if ($LASTEXITCODE -ne 0) { + throw "rustup target list --installed failed with exit code $LASTEXITCODE." + } + if ($InstalledTargets -notcontains $WindowsTarget) { + throw "Missing Rust target $WindowsTarget. Install with: rustup target add $WindowsTarget" + } + Write-Host " rust target: $WindowsTarget" + } else { + Write-Host " rust target: rustup not found; using rustc host $WindowsTarget" + } +} + +function Assert-RequiredFile { + param( + [string]$Path, + [string]$Description + ) + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + throw "Missing package input: $Description at $Path" + } +} + +function New-WindowsLaunchers { + param([string]$BinDir) + + New-Item -ItemType Directory -Force -Path $BinDir | Out-Null + + $CmdPath = Join-Path $BinDir "ourocode.cmd" + $CmdContent = @( + "@echo off", + "setlocal", + 'set "SCRIPT_DIR=%~dp0"', + 'set "ROOT_DIR=%SCRIPT_DIR%.."', + 'if exist "%ROOT_DIR%\bin\ourocode_tty.exe" set "OUROCODE_TTY=%ROOT_DIR%\bin\ourocode_tty.exe"', + 'escript.exe "%ROOT_DIR%\ourocode" %*', + "exit /b %ERRORLEVEL%" + ) + Set-Content -Path $CmdPath -Value $CmdContent -Encoding ASCII + + $Ps1Path = Join-Path $BinDir "ourocode.ps1" + $Ps1Content = @( + '$ErrorActionPreference = "Stop"', + '$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path', + '$rootDir = Resolve-Path (Join-Path $scriptDir "..")', + '$tty = Join-Path $rootDir "bin\ourocode_tty.exe"', + 'if (Test-Path -LiteralPath $tty) { $env:OUROCODE_TTY = $tty }', + '& escript.exe (Join-Path $rootDir "ourocode") @args', + 'exit $LASTEXITCODE' + ) + Set-Content -Path $Ps1Path -Value $Ps1Content -Encoding ASCII +} + +function Copy-PackageInputs { + param( + [string]$SourceRoot, + [string]$PackageRoot, + [bool]$IncludeBuiltHelper + ) + + Assert-RequiredFile (Join-Path $SourceRoot "ourocode") "escript" + Assert-RequiredFile (Join-Path $SourceRoot "install.ps1") "PowerShell installer" + Assert-RequiredFile (Join-Path $SourceRoot "uninstall.ps1") "PowerShell uninstaller" + Assert-RequiredFile (Join-Path $SourceRoot "README.md") "README" + + Copy-Item -LiteralPath (Join-Path $SourceRoot "ourocode") -Destination (Join-Path $PackageRoot "ourocode") -Force + Copy-Item -LiteralPath (Join-Path $SourceRoot "install.ps1") -Destination (Join-Path $PackageRoot "install.ps1") -Force + Copy-Item -LiteralPath (Join-Path $SourceRoot "uninstall.ps1") -Destination (Join-Path $PackageRoot "uninstall.ps1") -Force + Copy-Item -LiteralPath (Join-Path $SourceRoot "README.md") -Destination (Join-Path $PackageRoot "README.md") -Force + + $BinDir = Join-Path $PackageRoot "bin" + New-WindowsLaunchers -BinDir $BinDir + + $PrebuiltHelper = Join-Path $SourceRoot "bin\ourocode_tty.exe" + if (Test-Path -LiteralPath $PrebuiltHelper -PathType Leaf) { + Copy-Item -LiteralPath $PrebuiltHelper -Destination (Join-Path $BinDir "ourocode_tty.exe") -Force + Write-Host " helper: included prebuilt bin\ourocode_tty.exe" + Copy-BuiltLauncher -PackageRoot $PackageRoot -IncludeBuiltHelper $IncludeBuiltHelper + return + } + + if ($IncludeBuiltHelper) { + $BuiltHelper = Join-Path $RepoRoot "rust\ourocode_ipc\target\release\ourocode_tty.exe" + Assert-RequiredFile $BuiltHelper "built Windows TTY helper" + Copy-Item -LiteralPath $BuiltHelper -Destination (Join-Path $BinDir "ourocode_tty.exe") -Force + Write-Host " helper: included built bin\ourocode_tty.exe" + Copy-BuiltLauncher -PackageRoot $PackageRoot -IncludeBuiltHelper $IncludeBuiltHelper + } else { + Write-Host " helper: bin\ourocode_tty.exe not present; package will omit it" + } +} + +function Copy-BuiltLauncher { + param( + [string]$PackageRoot, + [bool]$IncludeBuiltHelper + ) + + if (-not $IncludeBuiltHelper) { + return + } + + $BuiltLauncher = Join-Path $RepoRoot "rust\ourocode_ipc\target\release\ourocode.exe" + Assert-RequiredFile $BuiltLauncher "built Windows launcher" + Copy-Item -LiteralPath $BuiltLauncher -Destination (Join-Path $PackageRoot "ourocode.exe") -Force + Write-Host " launcher: included built ourocode.exe" +} + +try { + if ([string]::IsNullOrWhiteSpace($Version)) { + $Version = Get-ProjectVersion + } + + if ($Configuration -ne "Release") { + throw "Unsupported build configuration '$Configuration'. Windows release packaging currently supports only Release." + } + + if ($Version -match '[\\/:*?"<>|]') { + throw "Version contains characters that are invalid for a Windows file name: $Version" + } + + $OutputRoot = Resolve-RepoPath $OutputDir + $PackageName = "ourocode-v$Version-windows-x64" + $StageParent = Join-Path $OutputRoot "_stage" + $PackageRoot = Join-Path $StageParent $PackageName + $ZipPath = Join-Path $OutputRoot "$PackageName.zip" + $ShaPath = "$ZipPath.sha256" + + Write-Step "packaging $PackageName" + Write-Host " configuration: $Configuration" + New-Item -ItemType Directory -Force -Path $OutputRoot | Out-Null + if (Test-Path -LiteralPath $PackageRoot) { + Remove-Item -LiteralPath $PackageRoot -Recurse -Force + } + if (Test-Path -LiteralPath $ZipPath) { + Remove-Item -LiteralPath $ZipPath -Force + } + if (Test-Path -LiteralPath $ShaPath) { + Remove-Item -LiteralPath $ShaPath -Force + } + New-Item -ItemType Directory -Force -Path $PackageRoot | Out-Null + + Push-Location $RepoRoot + try { + if (-not [string]::IsNullOrWhiteSpace($PrebuiltRoot)) { + $ResolvedPrebuiltRoot = Resolve-RepoPath $PrebuiltRoot + if (-not (Test-Path -LiteralPath $ResolvedPrebuiltRoot -PathType Container)) { + throw "PrebuiltRoot was provided but does not exist: $ResolvedPrebuiltRoot" + } + Write-Step "using prebuilt fixture root $ResolvedPrebuiltRoot" + Copy-PackageInputs -SourceRoot $ResolvedPrebuiltRoot -PackageRoot $PackageRoot -IncludeBuiltHelper $false + } else { + Assert-NormalBuildPrerequisites + Assert-RequiredFile (Join-Path $RepoRoot "install.ps1") "PowerShell installer" + Assert-RequiredFile (Join-Path $RepoRoot "uninstall.ps1") "PowerShell uninstaller" + Assert-RequiredFile (Join-Path $RepoRoot "README.md") "README" + + Write-Step "building Rust helper" + Invoke-Checked "cargo" @("build", "--release", "--manifest-path", ".\rust\ourocode_ipc\Cargo.toml", "--bin", "ourocode_tty") + + Write-Step "building Windows launcher" + Invoke-Checked "cargo" @("build", "--release", "--manifest-path", ".\rust\ourocode_ipc\Cargo.toml", "--bin", "ourocode") + + Write-Step "building Elixir escript" + Invoke-Checked "mix" @("escript.build") + + Copy-PackageInputs -SourceRoot $RepoRoot -PackageRoot $PackageRoot -IncludeBuiltHelper $true + } + } finally { + Pop-Location + } + + Write-Step "creating zip" + Compress-Archive -Path $PackageRoot -DestinationPath $ZipPath -Force + + Write-Step "writing SHA256" + $Hash = Get-FileHash -LiteralPath $ZipPath -Algorithm SHA256 + "{0} {1}" -f $Hash.Hash.ToLowerInvariant(), (Split-Path -Leaf $ZipPath) | Set-Content -Path $ShaPath -Encoding ASCII + + if (Test-Path -LiteralPath $StageParent) { + Remove-Item -LiteralPath $StageParent -Recurse -Force + } + + Write-Step "release ready" + Write-Host " $ZipPath" + Write-Host " $ShaPath" +} catch { + if ((Get-Variable -Name StageParent -ErrorAction SilentlyContinue) -and (Test-Path -LiteralPath $StageParent)) { + Remove-Item -LiteralPath $StageParent -Recurse -Force + } + Write-Error $_.Exception.Message + exit 1 +} diff --git a/test/ourocode/ipc/helper_port_test.exs b/test/ourocode/ipc/helper_port_test.exs index 92c1048..b30460c 100644 --- a/test/ourocode/ipc/helper_port_test.exs +++ b/test/ourocode/ipc/helper_port_test.exs @@ -4,15 +4,9 @@ defmodule Ourocode.IPC.HelperPortTest do alias Ourocode.IPC.HelperPort test "opens helper port, writes the initial frame, and exposes response lines" do - command = System.find_executable("sh") - assert is_binary(command) + {command, args} = Ourocode.Test.PortPrograms.echo_line_command("echo:") - script = """ - IFS= read -r line - printf '%s\\n' "echo:$line" - """ - - assert {:ok, port} = HelperPort.open_and_write(command, ["-c", script], "hello\n") + assert {:ok, port} = HelperPort.open_and_write(command, args, "hello\n") assert_receive {^port, {:data, {:eol, "echo:hello"}}}, 1_000 assert HelperPort.close(port) == :ok end @@ -29,10 +23,9 @@ defmodule Ourocode.IPC.HelperPortTest do end test "close is idempotent for already closed ports" do - command = System.find_executable("sh") - assert is_binary(command) + {command, args} = Ourocode.Test.PortPrograms.read_once_and_exit_command() - assert {:ok, port} = HelperPort.open_and_write(command, ["-c", "exit 0"], "hello\n") + assert {:ok, port} = HelperPort.open_and_write(command, args, "hello\n") assert HelperPort.close(port) == :ok assert HelperPort.close(port) == :ok end diff --git a/test/ourocode/ipc/rpc_test.exs b/test/ourocode/ipc/rpc_test.exs index ed4508f..6cd74b8 100644 --- a/test/ourocode/ipc/rpc_test.exs +++ b/test/ourocode/ipc/rpc_test.exs @@ -12,23 +12,44 @@ defmodule Ourocode.IPC.RPCTest do end test "invokes a helper over newline-delimited IPC and returns the correlated response result" do - command = System.find_executable("sh") - assert is_binary(command) - - script = """ - while IFS= read -r line; do - if printf '%s' "$line" | grep -q '"message_id":"req-roundtrip-1"' && - printf '%s' "$line" | grep -q '"message_type":"ipc.rpc.request"' && - printf '%s' "$line" | grep -q '"method":"helper.scan"' && - printf '%s' "$line" | grep -q '"action":"run"'; then - printf '%s\n' '{"version":1,"message_id":"res-roundtrip-1","message_type":"ipc.rpc.response","payload":{"request_id":"req-roundtrip-1","status":"ok","result":{"accepted":true,"transport":"stdio-jsonl","worker":"fake-rust-helper"}},"metadata":{"rust_worker":"fake-rust-helper"}}' - else - printf '%s\n' '{"version":1,"message_id":"res-roundtrip-1","message_type":"ipc.rpc.response","payload":{"request_id":"req-roundtrip-1","status":"error","error":{"code":"bad_request","message":"unexpected request frame"}}}' - fi - done - """ + {command, args} = + helper_command( + """ + while IFS= read -r line; do + if printf '%s' "$line" | grep -q '"message_id":"req-roundtrip-1"' && + printf '%s' "$line" | grep -q '"message_type":"ipc.rpc.request"' && + printf '%s' "$line" | grep -q '"method":"helper.scan"' && + printf '%s' "$line" | grep -q '"action":"run"'; then + printf '%s\n' '{"version":1,"message_id":"res-roundtrip-1","message_type":"ipc.rpc.response","payload":{"request_id":"req-roundtrip-1","status":"ok","result":{"accepted":true,"transport":"stdio-jsonl","worker":"fake-rust-helper"}},"metadata":{"rust_worker":"fake-rust-helper"}}' + else + printf '%s\n' '{"version":1,"message_id":"res-roundtrip-1","message_type":"ipc.rpc.response","payload":{"request_id":"req-roundtrip-1","status":"error","error":{"code":"bad_request","message":"unexpected request frame"}}}' + fi + done + """, + """ + @echo off + :loop + set "line=" + set /p "line=" + if errorlevel 1 exit /b 0 + set "has_message_id=" + set "has_message_type=" + set "has_method=" + set "has_action=" + if not "%line:"message_id":"req-roundtrip-1"=%"=="%line%" set "has_message_id=1" + if not "%line:"message_type":"ipc.rpc.request"=%"=="%line%" set "has_message_type=1" + if not "%line:"method":"helper.scan"=%"=="%line%" set "has_method=1" + if not "%line:"action":"run"=%"=="%line%" set "has_action=1" + if defined has_message_id if defined has_message_type if defined has_method if defined has_action ( + echo {"version":1,"message_id":"res-roundtrip-1","message_type":"ipc.rpc.response","payload":{"request_id":"req-roundtrip-1","status":"ok","result":{"accepted":true,"transport":"stdio-jsonl","worker":"fake-rust-helper"}},"metadata":{"rust_worker":"fake-rust-helper"}} + ) else ( + echo {"version":1,"message_id":"res-roundtrip-1","message_type":"ipc.rpc.response","payload":{"request_id":"req-roundtrip-1","status":"error","error":{"code":"bad_request","message":"unexpected request frame"}}} + ) + goto loop + """ + ) - {:ok, rpc} = RPC.start_link(command: command, args: ["-c", script]) + {:ok, rpc} = RPC.start_link(command: command, args: args) assert {:ok, %{ @@ -48,16 +69,24 @@ defmodule Ourocode.IPC.RPCTest do end test "returns a correlated timeout when a helper never replies" do - command = System.find_executable("sh") - assert is_binary(command) - - script = """ - while IFS= read -r _line; do - : - done - """ + {command, args} = + helper_command( + """ + while IFS= read -r _line; do + : + done + """, + """ + @echo off + :loop + set "line=" + set /p "line=" + if errorlevel 1 exit /b 0 + goto loop + """ + ) - {:ok, rpc} = RPC.start_link(command: command, args: ["-c", script]) + {:ok, rpc} = RPC.start_link(command: command, args: args) assert {:error, {:timeout, "req-stalled-1"}} = RPC.invoke( @@ -73,20 +102,33 @@ defmodule Ourocode.IPC.RPCTest do end test "returns structured Rust helper error replies without crashing the RPC process" do - command = System.find_executable("sh") - assert is_binary(command) - - script = """ - while IFS= read -r line; do - if printf '%s' "$line" | grep -q '"message_id":"req-helper-error"'; then - printf '%s\n' '{"version":1,"message_id":"res-helper-error","message_type":"ipc.rpc.response","payload":{"request_id":"req-helper-error","status":"error","error":{"code":"worker_failed","message":"helper failed","detail":{"exit_status":3,"stderr_tail":"bad input"}}},"metadata":{"rust_worker":"fake-rust-helper"}}' - elif printf '%s' "$line" | grep -q '"message_id":"req-after-helper-error"'; then - printf '%s\n' '{"version":1,"message_id":"res-after-helper-error","message_type":"ipc.rpc.response","payload":{"request_id":"req-after-helper-error","status":"ok","result":{"accepted":true}}}' - fi - done - """ + {command, args} = + helper_command( + """ + while IFS= read -r line; do + if printf '%s' "$line" | grep -q '"message_id":"req-helper-error"'; then + printf '%s\n' '{"version":1,"message_id":"res-helper-error","message_type":"ipc.rpc.response","payload":{"request_id":"req-helper-error","status":"error","error":{"code":"worker_failed","message":"helper failed","detail":{"exit_status":3,"stderr_tail":"bad input"}}},"metadata":{"rust_worker":"fake-rust-helper"}}' + elif printf '%s' "$line" | grep -q '"message_id":"req-after-helper-error"'; then + printf '%s\n' '{"version":1,"message_id":"res-after-helper-error","message_type":"ipc.rpc.response","payload":{"request_id":"req-after-helper-error","status":"ok","result":{"accepted":true}}}' + fi + done + """, + """ + @echo off + :loop + set "line=" + set /p "line=" + if errorlevel 1 exit /b 0 + if not "%line:req-helper-error=%"=="%line%" ( + echo {"version":1,"message_id":"res-helper-error","message_type":"ipc.rpc.response","payload":{"request_id":"req-helper-error","status":"error","error":{"code":"worker_failed","message":"helper failed","detail":{"exit_status":3,"stderr_tail":"bad input"}}},"metadata":{"rust_worker":"fake-rust-helper"}} + ) else if not "%line:req-after-helper-error=%"=="%line%" ( + echo {"version":1,"message_id":"res-after-helper-error","message_type":"ipc.rpc.response","payload":{"request_id":"req-after-helper-error","status":"ok","result":{"accepted":true}}} + ) + goto loop + """ + ) - {:ok, rpc} = RPC.start_link(command: command, args: ["-c", script]) + {:ok, rpc} = RPC.start_link(command: command, args: args) assert {:error, %{ @@ -115,20 +157,33 @@ defmodule Ourocode.IPC.RPCTest do end test "fails pending calls on malformed helper responses and remains reusable" do - command = System.find_executable("sh") - assert is_binary(command) - - script = """ - while IFS= read -r line; do - if printf '%s' "$line" | grep -q '"message_id":"req-malformed-response"'; then - printf '%s\n' '{malformed json' - elif printf '%s' "$line" | grep -q '"message_id":"req-after-malformed"'; then - printf '%s\n' '{"version":1,"message_id":"res-after-malformed","message_type":"ipc.rpc.response","payload":{"request_id":"req-after-malformed","status":"ok","result":{"accepted":true}}}' - fi - done - """ + {command, args} = + helper_command( + """ + while IFS= read -r line; do + if printf '%s' "$line" | grep -q '"message_id":"req-malformed-response"'; then + printf '%s\n' '{malformed json' + elif printf '%s' "$line" | grep -q '"message_id":"req-after-malformed"'; then + printf '%s\n' '{"version":1,"message_id":"res-after-malformed","message_type":"ipc.rpc.response","payload":{"request_id":"req-after-malformed","status":"ok","result":{"accepted":true}}}' + fi + done + """, + """ + @echo off + :loop + set "line=" + set /p "line=" + if errorlevel 1 exit /b 0 + if not "%line:req-malformed-response=%"=="%line%" ( + echo {malformed json + ) else if not "%line:req-after-malformed=%"=="%line%" ( + echo {"version":1,"message_id":"res-after-malformed","message_type":"ipc.rpc.response","payload":{"request_id":"req-after-malformed","status":"ok","result":{"accepted":true}}} + ) + goto loop + """ + ) - {:ok, rpc} = RPC.start_link(command: command, args: ["-c", script]) + {:ok, rpc} = RPC.start_link(command: command, args: args) assert {:error, {:malformed_response, {:expected_object_key, "malformed json"}}} = RPC.invoke( @@ -155,15 +210,21 @@ defmodule Ourocode.IPC.RPCTest do previous_flag = Process.flag(:trap_exit, true) try do - command = System.find_executable("sh") - assert is_binary(command) - - script = """ - IFS= read -r _line - exit 7 - """ - - {:ok, rpc} = RPC.start_link(command: command, args: ["-c", script]) + {command, args} = + helper_command( + """ + IFS= read -r _line + exit 7 + """, + """ + @echo off + set "line=" + set /p "line=" + exit /b 7 + """ + ) + + {:ok, rpc} = RPC.start_link(command: command, args: args) assert {:error, {:port_exit, 7}} = RPC.invoke( @@ -201,21 +262,33 @@ defmodule Ourocode.IPC.RPCTest do end test "cleans timed-out pending calls and ignores late helper responses" do - command = System.find_executable("sh") - assert is_binary(command) - - script = """ - while IFS= read -r line; do - if printf '%s' "$line" | grep -q '"message_id":"req-late-1"'; then - sleep 0.2 - printf '%s\n' '{"version":1,"message_id":"res-late-1","message_type":"ipc.rpc.response","payload":{"request_id":"req-late-1","status":"ok","result":{"late":true}}}' - elif printf '%s' "$line" | grep -q '"message_id":"req-after-timeout"'; then - printf '%s\n' '{"version":1,"message_id":"res-after-timeout","message_type":"ipc.rpc.response","payload":{"request_id":"req-after-timeout","status":"ok","result":{"accepted":true}}}' - fi - done - """ - - {:ok, rpc} = RPC.start_link(command: command, args: ["-c", script]) + {command, args} = + helper_command( + """ + while IFS= read -r line; do + if printf '%s' "$line" | grep -q '"message_id":"req-late-1"'; then + sleep 0.2 + printf '%s\n' '{"version":1,"message_id":"res-late-1","message_type":"ipc.rpc.response","payload":{"request_id":"req-late-1","status":"ok","result":{"late":true}}}' + elif printf '%s' "$line" | grep -q '"message_id":"req-after-timeout"'; then + printf '%s\n' '{"version":1,"message_id":"res-after-timeout","message_type":"ipc.rpc.response","payload":{"request_id":"req-after-timeout","status":"ok","result":{"accepted":true}}}' + fi + done + """, + """ + @echo off + set "line=" + set /p "line=" + if errorlevel 1 exit /b 0 + if not "%line:req-late-1=%"=="%line%" ( + ping -n 2 127.0.0.1 >nul + echo {"version":1,"message_id":"res-late-1","message_type":"ipc.rpc.response","payload":{"request_id":"req-late-1","status":"ok","result":{"late":true}}} + ) else if not "%line:req-after-timeout=%"=="%line%" ( + echo {"version":1,"message_id":"res-after-timeout","message_type":"ipc.rpc.response","payload":{"request_id":"req-after-timeout","status":"ok","result":{"accepted":true}}} + ) + """ + ) + + {:ok, rpc} = RPC.start_link(command: command, args: args) assert {:error, {:timeout, "req-late-1"}} = RPC.invoke( @@ -239,11 +312,20 @@ defmodule Ourocode.IPC.RPCTest do end test "rejects invalid helper RPC timeout values before opening a pending call" do - command = System.find_executable("sh") - assert is_binary(command) + {command, args} = + helper_command( + "while IFS= read -r _line; do :; done", + """ + @echo off + :loop + set "line=" + set /p "line=" + if errorlevel 1 exit /b 0 + goto loop + """ + ) - {:ok, rpc} = - RPC.start_link(command: command, args: ["-c", "while IFS= read -r _line; do :; done"]) + {:ok, rpc} = RPC.start_link(command: command, args: args) assert {:error, {:invalid_timeout, 0}} = RPC.invoke( @@ -257,24 +339,35 @@ defmodule Ourocode.IPC.RPCTest do end test "re-resolves configured Rust helper path and version for each invocation" do - command = System.find_executable("sh") - assert is_binary(command) - - helper_v1 = [ - "-c", - """ - IFS= read -r _line - printf '%s\n' '{"version":1,"message_id":"res-helper-version-1","message_type":"ipc.rpc.response","payload":{"request_id":"req-helper-version-1","status":"ok","result":{"helper_version":"v1"}}}' - """ - ] - - helper_v2 = [ - "-c", - """ - IFS= read -r _line - printf '%s\n' '{"version":1,"message_id":"res-helper-version-2","message_type":"ipc.rpc.response","payload":{"request_id":"req-helper-version-2","status":"ok","result":{"helper_version":"v2"}}}' - """ - ] + command = helper_shell!() + + helper_v1 = + helper_args( + """ + IFS= read -r _line + printf '%s\n' '{"version":1,"message_id":"res-helper-version-1","message_type":"ipc.rpc.response","payload":{"request_id":"req-helper-version-1","status":"ok","result":{"helper_version":"v1"}}}' + """, + """ + @echo off + set "line=" + set /p "line=" + echo {"version":1,"message_id":"res-helper-version-1","message_type":"ipc.rpc.response","payload":{"request_id":"req-helper-version-1","status":"ok","result":{"helper_version":"v1"}}} + """ + ) + + helper_v2 = + helper_args( + """ + IFS= read -r _line + printf '%s\n' '{"version":1,"message_id":"res-helper-version-2","message_type":"ipc.rpc.response","payload":{"request_id":"req-helper-version-2","status":"ok","result":{"helper_version":"v2"}}}' + """, + """ + @echo off + set "line=" + set /p "line=" + echo {"version":1,"message_id":"res-helper-version-2","message_type":"ipc.rpc.response","payload":{"request_id":"req-helper-version-2","status":"ok","result":{"helper_version":"v2"}}} + """ + ) Application.put_env(:ourocode, :rust_helpers, %{ scanner: %{path: command, args: helper_v1, version: "v1"} @@ -311,14 +404,15 @@ defmodule Ourocode.IPC.RPCTest do helper_path = Path.join( System.tmp_dir!(), - "ourocode-helper-replacement-#{System.unique_integer([:positive])}.sh" + "ourocode-helper-replacement-#{System.unique_integer([:positive])}#{helper_script_extension()}" ) on_exit(fn -> File.rm(helper_path) end) write_helper_executable!(helper_path, "req-helper-replacement-1", "v1") - {:ok, rpc} = RPC.start_link(command: helper_path) + {command, args} = helper_file_command(helper_path) + {:ok, rpc} = RPC.start_link(command: command, args: args) rpc_pid = rpc assert {:ok, %{"binary_generation" => "v1", "request_id" => "req-helper-replacement-1"}} = @@ -349,34 +443,46 @@ defmodule Ourocode.IPC.RPCTest do end test "supervised restart discards old helper process state and uses latest configured helper" do - command = System.find_executable("sh") - assert is_binary(command) + command = helper_shell!() marker = Path.join(System.tmp_dir!(), "ourocode-rpc-restart-#{System.unique_integer([:positive])}") worker_name = :"ourocode_rpc_restart_#{System.unique_integer([:positive])}" - helper_v1 = [ - "-c", - """ - IFS= read -r _line - touch "$1" - sleep 5 - printf '%s\n' '{"version":1,"message_id":"res-before-restart","message_type":"ipc.rpc.response","payload":{"request_id":"req-before-restart","status":"ok","result":{"helper_version":"v1","stale":true}}}' - """, - "helper-v1", - marker - ] - - helper_v2 = [ - "-c", - """ - IFS= read -r _line - printf '%s\n' '{"version":1,"message_id":"res-after-restart","message_type":"ipc.rpc.response","payload":{"request_id":"req-after-restart","status":"ok","result":{"helper_version":"v2","fresh_worker":true}}}' - """, - "helper-v2" - ] + helper_v1 = + helper_args( + """ + IFS= read -r _line + touch "$1" + sleep 5 + printf '%s\n' '{"version":1,"message_id":"res-before-restart","message_type":"ipc.rpc.response","payload":{"request_id":"req-before-restart","status":"ok","result":{"helper_version":"v1","stale":true}}}' + """, + """ + @echo off + set "line=" + set /p "line=" + type nul > "%~2" + ping -n 6 127.0.0.1 >nul + echo {"version":1,"message_id":"res-before-restart","message_type":"ipc.rpc.response","payload":{"request_id":"req-before-restart","status":"ok","result":{"helper_version":"v1","stale":true}}} + """, + ["helper-v1", marker] + ) + + helper_v2 = + helper_args( + """ + IFS= read -r _line + printf '%s\n' '{"version":1,"message_id":"res-after-restart","message_type":"ipc.rpc.response","payload":{"request_id":"req-after-restart","status":"ok","result":{"helper_version":"v2","fresh_worker":true}}}' + """, + """ + @echo off + set "line=" + set /p "line=" + echo {"version":1,"message_id":"res-after-restart","message_type":"ipc.rpc.response","payload":{"request_id":"req-after-restart","status":"ok","result":{"helper_version":"v2","fresh_worker":true}}} + """, + ["helper-v2"] + ) Application.put_env(:ourocode, :rust_helpers, %{ scanner: %{path: command, args: helper_v1, version: "v1"} @@ -462,9 +568,62 @@ defmodule Ourocode.IPC.RPCTest do defp restore_env(key, nil), do: Application.delete_env(:ourocode, key) defp restore_env(key, value), do: Application.put_env(:ourocode, key, value) + defp helper_command(unix_script, windows_script) do + {helper_shell!(), helper_args(unix_script, windows_script)} + end + + defp helper_shell! do + command = + if windows?() do + System.find_executable("cmd") + else + System.find_executable("sh") + end + + command || flunk("no portable helper shell found for IPC RPC tests") + end + + defp helper_args(unix_script, windows_script, extra_args \\ []) do + if windows?() do + path = + Path.join( + System.tmp_dir!(), + "ourocode-rpc-helper-#{System.unique_integer([:positive])}.cmd" + ) + + File.write!(path, windows_script) + on_exit(fn -> File.rm(path) end) + + [ + "/d", + "/q", + "/c", + path + | extra_args + ] + else + ["-c", unix_script | extra_args] + end + end + + defp helper_file_command(path) do + if windows?() do + {helper_shell!(), ["/d", "/q", "/c", path]} + else + {path, []} + end + end + + defp helper_script_extension do + if windows?(), do: ".cmd", else: ".sh" + end + defp write_helper_executable!(path, request_id, generation) do File.write!(path, helper_executable(request_id, generation)) - File.chmod!(path, 0o755) + + unless windows?() do + File.chmod!(path, 0o755) + end end defp helper_executable(request_id, generation) do @@ -481,16 +640,30 @@ defmodule Ourocode.IPC.RPCTest do ] |> IO.iodata_to_binary() - Enum.join( - [ - "#!/bin/sh", - "IFS= read -r _line", - "printf '%s\\n' '#{response}'" - ], - "\n" - ) + if windows?() do + Enum.join( + [ + "@echo off", + "set \"line=\"", + "set /p \"line=\"", + "echo #{response}" + ], + "\r\n" + ) + else + Enum.join( + [ + "#!/bin/sh", + "IFS= read -r _line", + "printf '%s\\n' '#{response}'" + ], + "\n" + ) + end end + defp windows?, do: match?({:win32, _}, :os.type()) + defp eventually?(fun, timeout_ms \\ 1_000) do deadline = System.monotonic_time(:millisecond) + timeout_ms eventually_until?(fun, deadline) diff --git a/test/ourocode/runtime/mcp_daemon/process_test.exs b/test/ourocode/runtime/mcp_daemon/process_test.exs index c1ad724..fe11b23 100644 --- a/test/ourocode/runtime/mcp_daemon/process_test.exs +++ b/test/ourocode/runtime/mcp_daemon/process_test.exs @@ -26,6 +26,76 @@ defmodule Ourocode.Runtime.McpDaemon.ProcessTest do ] end + test "spawn_plan uses direct executable invocation for default Windows launches" do + exe = "C:\\Program Files\\Ouroboros\\ouroboros.exe" + args = ["mcp", "serve", "--port", "4322"] + + plan = + DaemonProcess.spawn_plan( + exe, + args, + 4322 + ) + + if windows?() do + assert plan.shell == exe + assert plan.args == args + else + assert plan.shell == (System.find_executable("sh") || "/bin/sh") + + assert plan.args == [ + "-c", + "exec \"$0\" \"$@\" >\"#{plan.log_path}\" 2>&1", + "C:\\Program Files\\Ouroboros\\ouroboros.exe", + "mcp", + "serve", + "--port", + "4322" + ] + end + end + + test "spawn_plan executes a real Windows executable path containing spaces" do + if windows?() do + exe = "C:\\Program Files\\Git\\bin\\bash.exe" + assert File.exists?(exe) + + plan = DaemonProcess.spawn_plan(exe, ["-lc", "printf spawn_plan_exec_ok"], 4323) + File.rm(plan.log_path) + + {status, output} = run_spawn_plan(plan) + log_output = if File.exists?(plan.log_path), do: File.read!(plan.log_path), else: "" + + assert status == 0 + assert output <> log_output == "spawn_plan_exec_ok" + end + after + File.rm(Path.join(System.tmp_dir!(), "ourocode-mcp-4323.log")) + end + + test "spawn_plan does not route explicit Windows cmd shell through broken command string" do + if windows?() do + exe = "C:\\Program Files\\Git\\bin\\bash.exe" + shell = "C:\\Windows\\System32\\cmd.exe" + assert File.exists?(exe) + assert File.exists?(shell) + + plan = DaemonProcess.spawn_plan(exe, ["-lc", "printf cmd_spawn_ok"], 54322, shell) + File.rm(plan.log_path) + + assert plan.shell == exe + assert plan.args == ["-lc", "printf cmd_spawn_ok"] + + {status, output} = run_spawn_plan(plan) + log_output = if File.exists?(plan.log_path), do: File.read!(plan.log_path), else: "" + + assert status == 0 + assert output <> log_output == "cmd_spawn_ok" + end + after + File.rm(Path.join(System.tmp_dir!(), "ourocode-mcp-54322.log")) + end + test "stop is a safe no-op for non-spawned handles" do assert :ok == DaemonProcess.stop(nil) assert :ok == DaemonProcess.stop(%{mode: :external, url: "http://x/mcp"}) @@ -33,24 +103,92 @@ defmodule Ourocode.Runtime.McpDaemon.ProcessTest do end test "stop reaps a spawned OS process" do + command = System.find_executable("erl") + assert is_binary(command) + erl_port = - Port.open({:spawn_executable, "/bin/sh"}, [ + Port.open({:spawn_executable, command}, [ :binary, :exit_status, :hide, - args: ["-c", "exec sleep 30"] + args: ["-noshell", "-eval", "timer:sleep(infinity)."] ]) {:os_pid, os_pid} = Port.info(erl_port, :os_pid) - assert {_out, 0} = System.cmd("kill", ["-0", Integer.to_string(os_pid)]) + assert os_process_alive?(os_pid) assert :ok == DaemonProcess.stop(%{mode: :spawned, port: erl_port, os_pid: os_pid, url: "u"}) - Process.sleep(150) + refute_os_process_alive(os_pid) + end + + defp refute_os_process_alive(os_pid) do + deadline = System.monotonic_time(:millisecond) + 1_500 + + unless wait_until_dead(os_pid, deadline) do + flunk("expected spawned OS process #{os_pid} to be reaped") + end + end + + defp wait_until_dead(os_pid, deadline) do + cond do + not os_process_alive?(os_pid) -> + true - assert {_err, code} = - System.cmd("kill", ["-0", Integer.to_string(os_pid)], stderr_to_stdout: true) + System.monotonic_time(:millisecond) >= deadline -> + false - assert code != 0 + true -> + Process.sleep(25) + wait_until_dead(os_pid, deadline) + end end + + defp os_process_alive?(os_pid) when is_integer(os_pid) and os_pid > 0 do + if windows?() do + tasklist_contains_pid?(os_pid) + else + {_output, code} = + System.cmd("kill", ["-0", Integer.to_string(os_pid)], stderr_to_stdout: true) + + code == 0 + end + end + + defp tasklist_contains_pid?(os_pid) do + {output, _code} = + System.cmd("tasklist", ["/FI", "PID eq #{os_pid}", "/NH"], stderr_to_stdout: true) + + output + |> String.split() + |> Enum.member?(Integer.to_string(os_pid)) + end + + defp run_spawn_plan(plan) do + port = + Port.open({:spawn_executable, plan.shell}, [ + :binary, + :exit_status, + :hide, + :stderr_to_stdout, + args: plan.args + ]) + + collect_port(port, []) + end + + defp collect_port(port, chunks) do + receive do + {^port, {:data, data}} -> + collect_port(port, [data | chunks]) + + {^port, {:exit_status, status}} -> + {status, chunks |> Enum.reverse() |> IO.iodata_to_binary()} + after + 2_000 -> + flunk("spawn_plan did not exit") + end + end + + defp windows?, do: match?({:win32, _name}, :os.type()) end diff --git a/test/ourocode/runtime/mcp_daemon_test.exs b/test/ourocode/runtime/mcp_daemon_test.exs index 8692432..62fa059 100644 --- a/test/ourocode/runtime/mcp_daemon_test.exs +++ b/test/ourocode/runtime/mcp_daemon_test.exs @@ -9,6 +9,9 @@ defmodule Ourocode.Runtime.McpDaemonTest do use ExUnit.Case, async: false alias Ourocode.Runtime.McpDaemon + alias Ourocode.Test.PortPrograms + + import Ourocode.Test.OsProcessAssertions setup do saved = {System.get_env("OUROCODE_MCP_AUTOSTART"), System.get_env("OUROCODE_MCP_URL")} @@ -127,27 +130,23 @@ defmodule Ourocode.Runtime.McpDaemonTest do end test "stop/1 reaps the per-instance OS process (no orphan on tab close)" do + {command, args} = PortPrograms.long_running_command() + erl_port = - Port.open({:spawn_executable, "/bin/sh"}, [ + Port.open({:spawn_executable, command}, [ :binary, :exit_status, :hide, - args: ["-c", "exec sleep 30"] + args: args ]) {:os_pid, os_pid} = Port.info(erl_port, :os_pid) - assert {_out, 0} = System.cmd("kill", ["-0", Integer.to_string(os_pid)]) + assert_os_process_alive(os_pid) assert :ok == McpDaemon.stop(%{mode: :spawned, port: erl_port, os_pid: os_pid, url: "u"}) - # The signalled server is gone — `kill -0` now fails (no such process). - Process.sleep(150) - - assert {_err, code} = - System.cmd("kill", ["-0", Integer.to_string(os_pid)], stderr_to_stdout: true) - - assert code != 0 + refute_os_process_alive(os_pid) end test "describe/1 renders every mode" do diff --git a/test/ourocode/runtime/stream_supervisor_test.exs b/test/ourocode/runtime/stream_supervisor_test.exs index 19fa402..d8b3dd3 100644 --- a/test/ourocode/runtime/stream_supervisor_test.exs +++ b/test/ourocode/runtime/stream_supervisor_test.exs @@ -6,6 +6,7 @@ defmodule Ourocode.Runtime.StreamSupervisorTest do alias Ourocode.MCP.Transport.StreamableHTTP alias Ourocode.Runtime.Stream.{Child, Session, Transport} alias Ourocode.Runtime.Stream.Telemetry + alias Ourocode.Test.PortPrograms setup do original_stream_mailbox_capacity = Application.get_env(:ourocode, :stream_mailbox_capacity) @@ -836,14 +837,13 @@ defmodule Ourocode.Runtime.StreamSupervisorTest do Application.put_env(:ourocode, :stale_cleanup_timeout_ms, 100) configured_timeout_ms = Config.defaults().stale_cleanup_timeout_ms termination_budget_ms = configured_timeout_ms + 500 - command = System.find_executable("sh") - assert is_binary(command) + {command, args} = PortPrograms.long_running_command() port = Port.open({:spawn_executable, command}, [ :binary, :exit_status, - {:args, ["-c", "while IFS= read -r line; do :; done"]}, + {:args, args}, {:line, 65_536} ]) diff --git a/test/ourocode/terminal/tty_driver_test.exs b/test/ourocode/terminal/tty_driver_test.exs index 5f17044..761b9d5 100644 --- a/test/ourocode/terminal/tty_driver_test.exs +++ b/test/ourocode/terminal/tty_driver_test.exs @@ -1,5 +1,5 @@ defmodule Ourocode.Terminal.TtyDriverTest do - use ExUnit.Case, async: true + use ExUnit.Case, async: false alias Ourocode.Terminal.TtyDriver @@ -14,6 +14,44 @@ defmodule Ourocode.Terminal.TtyDriverTest do assert TtyDriver.helper_path([nil, missing, existing]) == existing end + test "helper_path resolves packaged Windows exe before other bundled helpers" do + # Given + root = temp_root!() + packaged_exe = touch!(root, "bin/ourocode_tty.exe") + touch!(root, "bin/ourocode_tty") + touch!(root, "rust/ourocode_ipc/target/release/ourocode_tty.exe") + + # When + actual = in_project_root(root, fn -> TtyDriver.helper_path() end) + + # Then + assert_same_path(actual, packaged_exe) + end + + test "helper_path falls back to source Windows exe before source Unix helper" do + # Given + root = temp_root!() + source_exe = touch!(root, "rust/ourocode_ipc/target/release/ourocode_tty.exe") + touch!(root, "rust/ourocode_ipc/target/release/ourocode_tty") + + # When + actual = in_project_root(root, fn -> TtyDriver.helper_path() end) + + # Then + assert_same_path(actual, source_exe) + end + + test "helper_path returns nil when no helper candidate exists" do + # Given + root = temp_root!() + + # When + actual = in_project_root(root, fn -> TtyDriver.helper_path() end) + + # Then + assert actual == nil + end + test "terminal control sequences enable and disable SGR mouse reporting" do assert TtyDriver.enter_sequence() =~ "?1003h" assert TtyDriver.enter_sequence() =~ "?1006h" @@ -28,9 +66,92 @@ defmodule Ourocode.Terminal.TtyDriverTest do assert TtyDriver.parse_header("bad header\n") == :error end + test "port_options use Port stdio protocol on Windows and fd 3/4 protocol on Unix" do + windows_options = TtyDriver.port_options({:win32, :nt}) + unix_options = TtyDriver.port_options({:unix, :linux}) + + assert windows_options == [:binary, :exit_status] + refute :hide in windows_options + refute :nouse_stdio in windows_options + + assert unix_options == [:binary, :exit_status, :nouse_stdio, :hide] + end + test "next_chunk surfaces file-cache notifications and poll ticks" do send(self(), {:file_cache_ready, ["lib/a.ex"]}) assert TtyDriver.next_chunk(nil, 0) == {:file_cache_ready, ["lib/a.ex"]} assert TtyDriver.next_chunk(nil, 0) == :tick end + + test "next_chunk decodes complete helper resize and redraw control frames" do + send(self(), {nil, {:data, "\e]777;ourocode-resize=132x43\a"}}) + assert TtyDriver.next_chunk(nil, 0) == {:resize, {132, 43}} + + send(self(), {nil, {:data, "\e]777;ourocode-control=redraw\a"}}) + assert TtyDriver.next_chunk(nil, 0) == {:control, :redraw} + end + + test "next_chunk ignores malformed helper control frames instead of surfacing raw bytes" do + send(self(), {nil, {:data, "\e]777;ourocode-resize=wide-short\a"}}) + assert TtyDriver.next_chunk(nil, 0) == :tick + end + + test "next_chunk preserves terminal paste bytes that merely contain helper-like text" do + paste = "\e[200~\e]777;ourocode-resize=132x43\a\e[201~" + + send(self(), {nil, {:data, paste}}) + assert TtyDriver.next_chunk(nil, 0) == {:ok, paste} + end + + test "next_chunk separates raw bytes before a coalesced helper frame" do + frame = "\e]777;ourocode-resize=132x43\a" + + send(self(), {nil, {:data, "a" <> frame}}) + assert TtyDriver.next_chunk(nil, 0) == {:ok, "a", frame} + end + + defp temp_root! do + root = Path.join(System.tmp_dir!(), "ourocode-tty-test-#{System.unique_integer([:positive])}") + File.mkdir_p!(root) + on_exit(fn -> File.rm_rf(root) end) + root + end + + defp touch!(root, relative_path) do + path = Path.join(root, relative_path) + path |> Path.dirname() |> File.mkdir_p!() + File.write!(path, "") + path + end + + defp in_project_root(root, fun) do + original_cwd = File.cwd!() + original_env = System.get_env("OUROCODE_TTY") + + try do + System.delete_env("OUROCODE_TTY") + File.cd!(root) + fun.() + after + File.cd!(original_cwd) + restore_env(original_env) + end + end + + defp restore_env(nil), do: System.delete_env("OUROCODE_TTY") + defp restore_env(value), do: System.put_env("OUROCODE_TTY", value) + + defp assert_same_path(left, right) do + if match?({:win32, _}, :os.type()) do + assert windows_path_key(left) == windows_path_key(right) + else + assert left == right + end + end + + defp windows_path_key(path) do + path + |> String.replace("\\", "/") + |> String.downcase() + end end diff --git a/test/ourocode/terminal/tui_driver_session_test.exs b/test/ourocode/terminal/tui_driver_session_test.exs index 2d0332a..b6368b2 100644 --- a/test/ourocode/terminal/tui_driver_session_test.exs +++ b/test/ourocode/terminal/tui_driver_session_test.exs @@ -29,6 +29,36 @@ defmodule Ourocode.Terminal.TuiDriverSessionTest do assert TuiState.file_cache(state) == ["lib/a.ex", "test/a_test.exs"] end + test "next_chunk stores helper resize events and returns typed resize", %{state: state} do + send(self(), {nil, {:data, "\e]777;ourocode-resize=111x31\a"}}) + + assert TuiDriverSession.next_chunk(state, 0) == {:resize, {111, 31}} + assert TuiState.size(state) == {111, 31} + end + + test "next_chunk forwards helper redraw control events without key decoding", %{state: state} do + send(self(), {nil, {:data, "\e]777;ourocode-control=redraw\a"}}) + + assert TuiDriverSession.next_chunk(state, 0) == {:control, :redraw} + end + + test "next_chunk strips coalesced helper frame between raw input chunks", %{state: state} do + send(self(), {nil, {:data, "a\e]777;ourocode-resize=111x31\ab"}}) + + assert TuiDriverSession.next_chunk(state, 0) == {:ok, "a"} + assert TuiDriverSession.next_chunk(state, 0) == {:resize, {111, 31}} + assert TuiState.size(state) == {111, 31} + assert TuiDriverSession.next_chunk(state, 0) == {:ok, "b"} + end + + test "next_chunk ignores private helper frames inside coalesced raw input", %{state: state} do + send(self(), {nil, {:data, "a\e]777;ourocode-control=bogus\ab"}}) + + assert TuiDriverSession.next_chunk(state, 0) == {:ok, "a"} + assert TuiDriverSession.next_chunk(state, 0) == :tick + assert TuiDriverSession.next_chunk(state, 0) == {:ok, "b"} + end + test "terminal control sequences enable SGR mouse reporting for ledger inspection" do assert TuiDriverSession.enter_sequence() =~ "?1003h" assert TuiDriverSession.enter_sequence() =~ "?1006h" diff --git a/test/ourocode/terminal/tui_input_loop_test.exs b/test/ourocode/terminal/tui_input_loop_test.exs index 17b461c..0585f28 100644 --- a/test/ourocode/terminal/tui_input_loop_test.exs +++ b/test/ourocode/terminal/tui_input_loop_test.exs @@ -189,6 +189,35 @@ defmodule Ourocode.Terminal.TuiInputLoopTest do refute_received :unexpected_answer end + test "ooo enter during an active interview dispatches the workflow instead of answering", %{ + callbacks: callbacks, + output: output, + state: state + } do + TuiState.edit_buffer(state, %{key: :paste, char: "ooo pm build onboarding"}) + + result = %{ + pane_snapshot: fn -> %{wonder_tool: detection(), paused: false} end, + wonder_answer: fn _payload -> + send(self(), :unexpected_answer) + {:ok, %{}} + end + } + + assert TuiInputLoop.handle_events( + [%{key: :enter}], + result, + output, + state, + 80, + 24, + callbacks + ) == {:submit, "ooo pm build onboarding"} + + assert_received {:handle_enter, "ooo pm build onboarding", 80, 24} + refute_received :unexpected_answer + end + test "slash input during active interview stays in composer instead of opening palette", %{ callbacks: callbacks, output: output, diff --git a/test/ourocode/terminal/tui_login_test.exs b/test/ourocode/terminal/tui_login_test.exs index 4980c68..41a264e 100644 --- a/test/ourocode/terminal/tui_login_test.exs +++ b/test/ourocode/terminal/tui_login_test.exs @@ -24,6 +24,26 @@ defmodule Ourocode.Terminal.TuiLoginTest do assert TuiLogin.codex_entry_code("abcd efghi") == "ABCDEFGHI" end + test "windows login opens device URL through the Windows URL handler" do + assert TuiLogin.open_url_command( + "https://auth.openai.com/codex/device", + {:win32, :nt}, + fn _name -> nil end + ) == + {"rundll32.exe", + ["url.dll,FileProtocolHandler", "https://auth.openai.com/codex/device"]} + end + + test "windows login copies device code through PowerShell clipboard" do + assert TuiLogin.windows_clipboard_command("J46LBMDBT", {:win32, :nt}, fn + "powershell.exe" -> "C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe" + _name -> nil + end) == + {"C:/Windows/System32/WindowsPowerShell/v1.0/powershell.exe", + ["-NoProfile", "-Command", "Set-Clipboard -Value $env:OUROCODE_CLIPBOARD_TEXT"], + [{"OUROCODE_CLIPBOARD_TEXT", "J46LBMDBT"}]} + end + test "claude login arms a pending paste step with the Claude Code authorize URL" do with_tmp_home(fn -> state = TuiState.start_link() diff --git a/test/test_helper.exs b/test/test_helper.exs index 869559e..09b6167 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1 +1,187 @@ ExUnit.start() + +defmodule Ourocode.Test.PathAssertions do + @moduledoc false + + import ExUnit.Assertions + + def assert_same_path(left, right) do + assert path_key(left) == path_key(right) + end + + def path_key(path) do + path + |> Path.expand() + |> String.replace("\\", "/") + |> maybe_downcase_windows_path() + end + + defp maybe_downcase_windows_path(path) do + case :os.type() do + {:win32, _name} -> String.downcase(path) + _other -> path + end + end +end + +defmodule Ourocode.Test.PortPrograms do + @moduledoc false + + import ExUnit.Assertions + + def long_running_command do + erl = System.find_executable("erl") || System.find_executable("erl.exe") + assert is_binary(erl), "expected erl executable for portable long-running port test helper" + + {erl, ["-noshell", "-eval", "timer:sleep(infinity)."]} + end + + def echo_line_command(prefix) when is_binary(prefix) do + elixir_program(""" + IO.stream(:stdio, :line) + |> Enum.each(fn line -> + line = line |> String.trim_trailing("\\n") |> String.trim_trailing("\\r") + IO.puts(#{inspect(prefix)} <> line) + end) + """) + end + + def read_once_and_exit_command do + elixir_program(""" + IO.read(:stdio, :line) + System.halt(0) + """) + end + + def shell_script(script) when is_binary(script) do + operations = + script + |> normalize_printf_newlines() + |> String.split("\n") + |> Enum.flat_map(&parse_shell_line/1) + + elixir_program(""" + operations = #{inspect(operations, limit: :infinity, printable_limit: :infinity)} + + IO.stream(:stdio, :line) + |> Enum.each(fn line -> + line = line |> String.trim_trailing("\\n") |> String.trim_trailing("\\r") + + Enum.each(operations, fn + {:print, value} -> IO.puts(value) + {:echo_line, prefix} -> IO.puts(prefix <> line) + {:sleep, ms} -> Process.sleep(ms) + end) + end) + """) + end + + defp elixir_program(source) do + elixir = System.find_executable("elixir") || System.find_executable("elixir.bat") + assert is_binary(elixir), "expected elixir executable for portable port test helper" + + path = + Path.join( + System.tmp_dir!(), + "ourocode-port-program-#{System.unique_integer([:positive])}.exs" + ) + + File.write!(path, "File.rm(__ENV__.file)\n" <> source) + + {elixir, [path]} + end + + defp normalize_printf_newlines(script) do + String.replace(script, "%s\n'", "%s\\n'") + end + + defp parse_shell_line(raw_line) do + line = String.trim(raw_line) + + cond do + line == "" -> + [] + + line == "done" -> + [] + + String.starts_with?(line, "while ") -> + [] + + String.starts_with?(line, "IFS=") -> + [] + + line == "exit 0" -> + [] + + line == "sleep 1" -> + [{:sleep, 1_000}] + + line == ~s(printf '%s\\n' "echo:$line") -> + [{:echo_line, "echo:"}] + + match = Regex.run(~r/^printf '%s\\n' '(.*)'$/, line) -> + [_full, value] = match + [{:print, value}] + + true -> + flunk("unsupported portable shell test line: #{inspect(line)}") + end + end +end + +defmodule Ourocode.Test.OsProcessAssertions do + @moduledoc false + + import ExUnit.Assertions + + def assert_os_process_alive(os_pid) do + assert os_process_alive?(os_pid), "expected OS process #{os_pid} to be alive" + end + + def refute_os_process_alive(os_pid) do + deadline = System.monotonic_time(:millisecond) + 1_500 + + unless wait_until_dead(os_pid, deadline) do + flunk("expected OS process #{os_pid} to be reaped") + end + end + + defp wait_until_dead(os_pid, deadline) do + cond do + not os_process_alive?(os_pid) -> + true + + System.monotonic_time(:millisecond) >= deadline -> + false + + true -> + Process.sleep(25) + wait_until_dead(os_pid, deadline) + end + end + + defp os_process_alive?(os_pid) when is_integer(os_pid) and os_pid > 0 do + if windows?() do + tasklist_contains_pid?(os_pid) + else + {_output, code} = + System.cmd("kill", ["-0", Integer.to_string(os_pid)], stderr_to_stdout: true) + + code == 0 + end + end + + defp tasklist_contains_pid?(os_pid) do + command = System.find_executable("tasklist") || "tasklist" + + {output, _code} = + System.cmd(command, ["/FI", "PID eq #{os_pid}", "/NH"], stderr_to_stdout: true) + + output + |> String.split() + |> Enum.member?(Integer.to_string(os_pid)) + end + + defp windows?, do: match?({:win32, _name}, :os.type()) +end