From ef44542f1d0f77282f8236869a2a8ac54580191f Mon Sep 17 00:00:00 2001 From: 777genius Date: Tue, 14 Apr 2026 20:07:57 +0300 Subject: [PATCH] feat(tmux): add hybrid installer flow --- docs/FEATURE_ARCHITECTURE_STANDARD.md | 297 ++ docs/research/tmux-hybrid-installer-plan.md | 2443 +++++++++++++++++ electron.vite.config.ts | 3 + eslint.config.js | 179 ++ package.json | 3 + src/features/tmux-installer/contracts/api.ts | 11 + .../tmux-installer/contracts/channels.ts | 7 + src/features/tmux-installer/contracts/dto.ts | 109 + .../tmux-installer/contracts/index.ts | 3 + .../ports/TmuxInstallerRunnerPort.ts | 5 + .../ports/TmuxInstallerSnapshotPort.ts | 5 + .../application/ports/TmuxStatusSourcePort.ts | 6 + .../use-cases/CancelTmuxInstallUseCase.ts | 13 + .../GetTmuxInstallerSnapshotUseCase.ts | 14 + .../use-cases/GetTmuxStatusUseCase.ts | 14 + .../use-cases/InstallTmuxUseCase.ts | 13 + .../SubmitTmuxInstallerInputUseCase.ts | 13 + .../__tests__/tmuxInstallerUseCases.test.ts | 98 + .../buildTmuxAutoInstallCapability.test.ts | 95 + .../buildTmuxEffectiveAvailability.test.ts | 82 + .../buildTmuxAutoInstallCapability.ts | 192 ++ .../buildTmuxEffectiveAvailability.ts | 93 + .../input/ipc/registerTmuxInstallerIpc.ts | 84 + .../TmuxInstallerProgressPresenter.ts | 17 + .../runtime/TmuxInstallerRunnerAdapter.ts | 607 ++++ .../TmuxInstallerRunnerAdapter.test.ts | 369 +++ .../output/sources/TmuxStatusSourceAdapter.ts | 268 ++ .../__tests__/TmuxStatusSourceAdapter.test.ts | 103 + .../composition/createTmuxInstallerFeature.ts | 81 + .../main/composition/runtimeSupport.ts | 24 + src/features/tmux-installer/main/index.ts | 12 + .../installer/TmuxCommandRunner.ts | 86 + .../installer/TmuxInstallStrategyResolver.ts | 443 +++ .../installer/TmuxInstallTerminalSession.ts | 88 + .../platform/TmuxPackageManagerResolver.ts | 123 + .../platform/TmuxPlatformResolver.ts | 72 + .../runtime/TmuxPlatformCommandExecutor.ts | 109 + .../TmuxPlatformCommandExecutor.test.ts | 71 + .../wsl/TmuxWslPreferenceStore.ts | 79 + .../main/infrastructure/wsl/TmuxWslService.ts | 481 ++++ .../wsl/WindowsElevatedStepRunner.ts | 214 ++ .../wsl/__tests__/TmuxWslService.test.ts | 177 ++ .../WindowsElevatedStepRunner.test.ts | 71 + .../preload/createTmuxInstallerBridge.ts | 43 + src/features/tmux-installer/preload/index.ts | 1 + .../adapters/TmuxInstallerBannerAdapter.ts | 127 + .../TmuxInstallerBannerAdapter.test.ts | 261 ++ .../__tests__/useTmuxInstallerBanner.test.tsx | 237 ++ .../renderer/hooks/useTmuxInstallerBanner.ts | 174 ++ src/features/tmux-installer/renderer/index.ts | 1 + .../renderer/ui/TmuxInstallerBannerView.tsx | 254 ++ .../renderer/utils/formatTmuxInstallerText.ts | 63 + src/main/index.ts | 5 +- src/main/ipc/tmux.ts | 139 +- .../services/team/TeamProvisioningService.ts | 3 +- src/main/services/team/runtimeTeammateMode.ts | 40 +- src/preload/constants/ipcChannels.ts | 11 +- src/preload/index.ts | 12 +- src/renderer/api/httpClient.ts | 80 +- .../components/dashboard/TmuxStatusBanner.tsx | 348 +-- src/shared/types/tmux.ts | 16 +- .../services/team/runtimeTeammateMode.test.ts | 52 + tsconfig.json | 1 + tsconfig.node.json | 11 +- vitest.config.ts | 3 +- 65 files changed, 8608 insertions(+), 551 deletions(-) create mode 100644 docs/FEATURE_ARCHITECTURE_STANDARD.md create mode 100644 docs/research/tmux-hybrid-installer-plan.md create mode 100644 src/features/tmux-installer/contracts/api.ts create mode 100644 src/features/tmux-installer/contracts/channels.ts create mode 100644 src/features/tmux-installer/contracts/dto.ts create mode 100644 src/features/tmux-installer/contracts/index.ts create mode 100644 src/features/tmux-installer/core/application/ports/TmuxInstallerRunnerPort.ts create mode 100644 src/features/tmux-installer/core/application/ports/TmuxInstallerSnapshotPort.ts create mode 100644 src/features/tmux-installer/core/application/ports/TmuxStatusSourcePort.ts create mode 100644 src/features/tmux-installer/core/application/use-cases/CancelTmuxInstallUseCase.ts create mode 100644 src/features/tmux-installer/core/application/use-cases/GetTmuxInstallerSnapshotUseCase.ts create mode 100644 src/features/tmux-installer/core/application/use-cases/GetTmuxStatusUseCase.ts create mode 100644 src/features/tmux-installer/core/application/use-cases/InstallTmuxUseCase.ts create mode 100644 src/features/tmux-installer/core/application/use-cases/SubmitTmuxInstallerInputUseCase.ts create mode 100644 src/features/tmux-installer/core/application/use-cases/__tests__/tmuxInstallerUseCases.test.ts create mode 100644 src/features/tmux-installer/core/domain/policies/__tests__/buildTmuxAutoInstallCapability.test.ts create mode 100644 src/features/tmux-installer/core/domain/policies/__tests__/buildTmuxEffectiveAvailability.test.ts create mode 100644 src/features/tmux-installer/core/domain/policies/buildTmuxAutoInstallCapability.ts create mode 100644 src/features/tmux-installer/core/domain/policies/buildTmuxEffectiveAvailability.ts create mode 100644 src/features/tmux-installer/main/adapters/input/ipc/registerTmuxInstallerIpc.ts create mode 100644 src/features/tmux-installer/main/adapters/output/presenters/TmuxInstallerProgressPresenter.ts create mode 100644 src/features/tmux-installer/main/adapters/output/runtime/TmuxInstallerRunnerAdapter.ts create mode 100644 src/features/tmux-installer/main/adapters/output/runtime/__tests__/TmuxInstallerRunnerAdapter.test.ts create mode 100644 src/features/tmux-installer/main/adapters/output/sources/TmuxStatusSourceAdapter.ts create mode 100644 src/features/tmux-installer/main/adapters/output/sources/__tests__/TmuxStatusSourceAdapter.test.ts create mode 100644 src/features/tmux-installer/main/composition/createTmuxInstallerFeature.ts create mode 100644 src/features/tmux-installer/main/composition/runtimeSupport.ts create mode 100644 src/features/tmux-installer/main/index.ts create mode 100644 src/features/tmux-installer/main/infrastructure/installer/TmuxCommandRunner.ts create mode 100644 src/features/tmux-installer/main/infrastructure/installer/TmuxInstallStrategyResolver.ts create mode 100644 src/features/tmux-installer/main/infrastructure/installer/TmuxInstallTerminalSession.ts create mode 100644 src/features/tmux-installer/main/infrastructure/platform/TmuxPackageManagerResolver.ts create mode 100644 src/features/tmux-installer/main/infrastructure/platform/TmuxPlatformResolver.ts create mode 100644 src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts create mode 100644 src/features/tmux-installer/main/infrastructure/runtime/__tests__/TmuxPlatformCommandExecutor.test.ts create mode 100644 src/features/tmux-installer/main/infrastructure/wsl/TmuxWslPreferenceStore.ts create mode 100644 src/features/tmux-installer/main/infrastructure/wsl/TmuxWslService.ts create mode 100644 src/features/tmux-installer/main/infrastructure/wsl/WindowsElevatedStepRunner.ts create mode 100644 src/features/tmux-installer/main/infrastructure/wsl/__tests__/TmuxWslService.test.ts create mode 100644 src/features/tmux-installer/main/infrastructure/wsl/__tests__/WindowsElevatedStepRunner.test.ts create mode 100644 src/features/tmux-installer/preload/createTmuxInstallerBridge.ts create mode 100644 src/features/tmux-installer/preload/index.ts create mode 100644 src/features/tmux-installer/renderer/adapters/TmuxInstallerBannerAdapter.ts create mode 100644 src/features/tmux-installer/renderer/adapters/__tests__/TmuxInstallerBannerAdapter.test.ts create mode 100644 src/features/tmux-installer/renderer/hooks/__tests__/useTmuxInstallerBanner.test.tsx create mode 100644 src/features/tmux-installer/renderer/hooks/useTmuxInstallerBanner.ts create mode 100644 src/features/tmux-installer/renderer/index.ts create mode 100644 src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx create mode 100644 src/features/tmux-installer/renderer/utils/formatTmuxInstallerText.ts create mode 100644 test/main/services/team/runtimeTeammateMode.test.ts diff --git a/docs/FEATURE_ARCHITECTURE_STANDARD.md b/docs/FEATURE_ARCHITECTURE_STANDARD.md new file mode 100644 index 00000000..913970ce --- /dev/null +++ b/docs/FEATURE_ARCHITECTURE_STANDARD.md @@ -0,0 +1,297 @@ +# Feature Architecture Standard + +**Status**: team standard +**Reference implementation**: `src/features/recent-projects` + +This document defines the default architecture for medium and large features in this repository. + +## Goals + +- keep business rules isolated from Electron-specific runtime details +- make features easier to scale, test, and review +- keep renderer code closer to browser and Tauri portability +- enforce architecture with tooling, not only with code review comments + +## Canonical Template + +```text +src/features// + contracts/ + core/ + domain/ + application/ + main/ + composition/ + adapters/ + input/ + output/ + infrastructure/ + preload/ + renderer/ +``` + +Use this template by default when a feature: + +- spans more than one process boundary +- introduces its own use case or business policy +- needs its own transport bridge or integration surface +- is expected to grow with new providers, sources, or presentation flows + +## Layer Responsibilities + +### `contracts/` + +Cross-process public API for the feature. + +Allowed content: + +- DTOs +- API fragment types +- IPC or route constants + +Not allowed: + +- store access +- Electron APIs +- business orchestration + +### `core/domain/` + +Pure business rules and invariants. + +Examples: + +- merge policies +- provider-agnostic models +- selection rules +- dedupe logic + +Not allowed: + +- infrastructure access +- framework access +- side effects + +### `core/application/` + +Use cases and ports. + +Examples: + +- orchestration flow +- output ports +- cache ports +- source ports +- response models + +Not allowed: + +- Electron, Fastify, React, Zustand, child processes + +### `main/composition/` + +Feature composition root in the main process. + +Responsibilities: + +- instantiate infrastructure +- wire adapters +- wire use cases +- expose a small facade to app shell entrypoints + +### `main/adapters/input/` + +Driving adapters for the main process. + +Examples: + +- IPC handlers +- HTTP route registration + +Responsibilities: + +- translate transport input into use case calls +- keep transport concerns out of use cases + +### `main/adapters/output/` + +Driven adapters that implement application ports. + +Examples: + +- presenters +- source adapters + +Responsibilities: + +- translate between external data and core models +- stay thin around infrastructure helpers + +### `main/infrastructure/` + +Concrete technical implementation details. + +Examples: + +- file system adapters +- JSON-RPC transport clients +- binary discovery +- cache implementation +- git identity helpers + +Responsibilities: + +- know about runtime, process, OS, or protocol details + +### `preload/` + +Thin transport bridge between renderer and main. + +Responsibilities: + +- expose a feature API fragment +- depend on `contracts/` + +Not allowed: + +- main composition code +- renderer logic + +### `renderer/` + +Feature presentation and interaction. + +Recommended structure: + +```text +renderer/ + index.ts + adapters/ + hooks/ + ui/ + utils/ +``` + +Responsibilities: + +- `ui/` renders +- `hooks/` orchestrate interaction and transport usage +- `adapters/` transform DTOs into view models +- `utils/` contain small pure renderer helpers + +## Import Rules + +### Public entrypoints only + +Outside the feature, import only: + +- `@features//contracts` +- `@features//main` +- `@features//preload` +- `@features//renderer` + +Do not deep-import feature internals from app shell or from other features. + +### Core isolation + +`core/domain` must not import: + +- `@main/*` +- `@renderer/*` +- `@preload/*` +- adapters +- infrastructure +- Electron APIs +- Fastify +- child process modules + +`core/application` must not import: + +- `main/*` +- `renderer/*` +- Electron APIs +- Fastify +- child process modules + +### UI isolation + +`renderer/ui` must not import: + +- `@renderer/api` +- `@renderer/store` +- `@main/*` +- Electron APIs + +Push transport and store access into feature hooks or adapters. + +## Browser and Tauri Friendly Guidance + +The default transport direction should be: + +`renderer -> feature contracts -> app api abstraction -> preload/http adapter` + +This keeps renderer code closer to: + +- browser mode through HTTP adapters +- a future Tauri bridge +- alternative shells with minimal feature rewrites + +To keep that path clean: + +- never call `window.electronAPI` directly inside feature UI or hooks +- go through shared renderer API adapters +- keep Electron-specific concerns in `main/` and `preload/` +- keep business rules in `core/` + +## When To Use The Full Slice + +Use the full template when a feature has: + +- its own business rules +- its own merge or filtering policy +- transport wiring +- more than one adapter +- a roadmap beyond a one-off screen tweak + +## When A Thin Slice Is Enough + +A smaller feature may skip `core/` and `preload/` when it is: + +- purely presentational +- only reshaping already-owned data +- not adding a new use case +- not adding a new transport boundary + +## Definition Of Done For A Reference Feature + +A feature is reference-quality when: + +- structure matches the canonical template +- core is side-effect free +- app shell imports only public entrypoints +- renderer UI is dumb and presentational +- at least the main domain and application rules are tested +- architecture is enforced by lint rules +- feature has a concise standard or plan doc if it introduces a new pattern + +## Recommended Test Coverage + +For medium and large features, cover at least: + +- domain policy tests +- application use case tests +- critical renderer interaction utilities +- one adapter-level mapping test + +## Recent Projects As The Reference + +`src/features/recent-projects` is the first slice that follows this standard end-to-end. + +Use it as the example for: + +- contracts ownership +- core/application separation +- composition-root wiring +- renderer dumb UI + hook orchestration +- browser-friendly transport direction +- feature-level lint guard rails diff --git a/docs/research/tmux-hybrid-installer-plan.md b/docs/research/tmux-hybrid-installer-plan.md new file mode 100644 index 00000000..f3869558 --- /dev/null +++ b/docs/research/tmux-hybrid-installer-plan.md @@ -0,0 +1,2443 @@ +# План: Hybrid tmux Installer для Desktop App + +Дата: 2026-04-14 + +Рекомендуемый подход: `Hybrid installer` + +Оценка подхода: 🎯 9 🛡️ 8 🧠 6 + +Примерный объём первой качественной реализации: +- `macOS/Linux installer + UI + status/progress + manual fallback` - `900-1400` строк +- `Windows WSL wizard + richer status` - ещё `700-1200` строк +- `Windows runtime enablement через WSL tmux` - ещё `600-1200` строк + +Итого полноценный вариант, где Windows не просто умеет "установить", а реально получает пользу от `tmux` - примерно `2200-3800` строк. + +## 1. TL;DR + +Нужно сделать отдельный feature slice `tmux-installer`, где main-side orchestration layer по UX-паттерну похож на существующий `CliInstallerService`, но не копирует его буквально. + +Ключевая идея: +- `macOS` - автодетект `Homebrew`, fallback на `MacPorts`, иначе manual install +- `Linux` - автодетект native package manager, установка через `sudo` в PTY, честный step-based progress +- `Windows` - не притворяться native installer'ом, а сделать честный `WSL wizard` + проверку/re-check каждого шага + +Самый важный architectural gotcha: + +⚠️ Сейчас Windows-путь в приложении вообще не использует `tmux`, даже если пользователь сам его поставит в WSL. + +Это видно прямо в коде: +- `src/main/services/team/runtimeTeammateMode.ts` жёстко отключает process/tmux path на `win32` +- `src/main/ipc/tmux.ts` проверяет только host `tmux`, а не `wsl ... tmux` + +Следствие: +- сделать только installer на Windows недостаточно +- если хотим честно обещать "лучший опыт после установки", нужно добавить хотя бы базовую WSL-aware tmux detection +- если хотим реальный runtime gain на Windows, нужно отдельно включить WSL tmux path в runtime-решении teammate mode + +## 2. Цели + +### Продуктовые цели + +- Пользователь может установить `tmux` максимально удобно из UI +- UI честно показывает, что происходит: `checking`, `installing`, `verifying`, `completed`, `error` +- Если установка не удалась, пользователь не остаётся в тупике +- Manual fallback всегда есть и всегда OS-specific +- Никаких фейковых "100% success", если verification не прошло +- Ошибки формулируются человеческим языком, а не сырым stack trace + +### Технические цели + +- Не ломать текущий `TmuxStatusBanner` +- Сделать фичу по canonical standard из `docs/FEATURE_ARCHITECTURE_STANDARD.md` + - source of truth для новой фичи - `src/features/tmux-installer/` + - `src/renderer/components/dashboard/TmuxStatusBanner.tsx` должен стать thin wrapper или compatibility entrypoint, который импортирует только public renderer entrypoint фичи +- Переиспользовать существующие примитивы: + - `CliInstallerService` как референс по progress/event architecture + - `PtyTerminalService` как база для PTY lifecycle, но не полагаться на него "как есть" + - `TerminalModal` только как визуальный reference, не как источник истины для installer flow + - shell env resolution из существующих infra helpers +- Сделать state machine, которую можно тестировать unit/integration тестами +- Не делать platform-specific хаос прямо в React-компоненте + +### Не-цели v1 + +- Не собирать `tmux` из source автоматически +- Не устанавливать Homebrew автоматически +- Не поддерживать экспериментальные native Windows forks `tmux` по умолчанию +- Не делать "реальный процент" там, где package manager его не даёт +- Не пытаться тихо обходить OS security model + +## 3. Внешние факты, на которых строится план + +- tmux official install wiki перечисляет package manager команды для Linux/macOS и отдельно static binaries только для common Linux/macOS platforms: [tmux Installing wiki](https://github.com/tmux/tmux/wiki/Installing) +- Homebrew formula для `tmux` сейчас показывает `brew install tmux`, bottle support для macOS и Linux, stable `3.6a`: [Homebrew tmux](https://formulae.brew.sh/formula/tmux) +- MacPorts для `tmux` сейчас рекомендует `sudo port install tmux`, версия `3.6a`: [MacPorts tmux](https://ports.macports.org/port/tmux/) +- Microsoft для WSL пишет: открыть PowerShell в administrator mode, выполнить `wsl --install`, затем restart machine: [Install WSL](https://learn.microsoft.com/en-us/windows/wsl/install) +- Microsoft также документирует `wsl --list --online`, `wsl --distribution ... --user ...`, `wsl --status`, `wsl --shutdown`: [Basic commands for WSL](https://learn.microsoft.com/en-us/windows/wsl/basic-commands) +- Microsoft отдельно держит manual WSL install path для older Windows builds / server-like scenarios: [Manual installation steps for older versions of WSL](https://learn.microsoft.com/en-us/windows/wsl/install-manual) + +## 3.1 Самые рискованные места плана после перепроверки + +Это секции, где изначально была наименьшая уверенность и которые были усилены после дополнительной проверки. + +### ⚠️ Windows installer != Windows runtime support + +Самый критичный момент: +- `WSL wizard` сам по себе ещё не даёт реальной выгоды рантайму +- пока `runtimeTeammateMode` и `tmux status` не станут `WSL-aware`, Windows-часть будет только подготавливать окружение + +Именно поэтому в этом плане Windows runtime enablement выделен как обязательный follow-up, а не "nice to have". + +### ⚠️ Не надо по умолчанию ставить tmux внутри WSL как `root` + +Изначальная идея с: + +```powershell +wsl --distribution Ubuntu --user root -- sh -lc "apt-get install -y tmux" +``` + +слишком оптимистична для v1. + +Почему это риск: +- distro может быть ещё не bootstrap-ready +- поведение imported/custom distros менее предсказуемо +- мы можем получить "сработало технически, но пользователь не понимает, что произошло" + +Более надёжное решение: +- сначала довести distro до явного `bootstrapped` состояния +- затем запускать install flow через PTY внутри `wsl.exe` +- по умолчанию ставить как обычный Linux-user через `sudo`, а не скрыто через root +- `--user root` оставить только как optional future optimization, не как default v1 path + +### ⚠️ Linux immutable distros надо считать unsupported для v1 auto-install + +Если это: +- `rpm-ostree` +- Silverblue / Kinoite +- transactional-update / MicroOS + +то "обычный package manager install в хост" либо не тот путь, либо вообще misleading UX. + +Надёжнее: +- детектить immutable-host признаки +- сразу переводить пользователя в `manual-only guidance` +- не делать вид, что обычный `dnf install tmux` или `zypper install tmux` там надёжен + +### ⚠️ Не надо делать `apt-get update` всегда перед install + +Изначальный пример с: + +```bash +apt-get update && apt-get install -y tmux +``` + +практически рабочий, но хуже как default: +- медленнее +- больше сетевых точек отказа +- лишний шум в логах + +Надёжнее: +- сначала пробовать `install` +- если ошибка похожа на stale metadata / package not found due to old index, делать controlled retry с `update` + +### ⚠️ Windows elevation надо проектировать как отдельный тип шага, а не как "ещё один child process" + +Это место долго оставалось самым неформализованным. + +Проблема: +- WSL core install часто требует elevation +- типичный способ вызвать UAC это PowerShell `Start-Process -Verb RunAs` +- по официальному синтаксису `Start-Process` режим с `-Verb` относится к `Use Shell Execute`, а redirect-параметры живут в другом parameter set, поэтому надёжного "RunAs + live redirected stdout/stderr в тот же процесс" по умолчанию ожидать нельзя. Это мой вывод из PowerShell docs: [Start-Process](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.management/start-process?view=powershell-7.6) + +Следствие: +- elevated Windows step нельзя моделировать как обычный `spawn child and stream logs` +- это должен быть отдельный class of step в installer state machine + +Надёжнее: +- либо делать `external elevated step + fresh probe afterwards` +- либо, если очень нужны diagnostics, запускать временный elevated helper script, который пишет итоговый JSON/status file в temp location, а app его потом читает +- но даже в варианте с status file финальное решение всё равно принимать только после fresh system probe + +### ⚠️ Нельзя строить Windows path вокруг "default distro" как единственного источника истины + +Это ещё один недооценённый риск. + +Проблема: +- пользователь может установить `tmux` в `Ubuntu` +- потом default distro поменяется на `Debian` или custom distro +- если приложение смотрит только на default distro, UX станет "tmux снова пропал", хотя на самом деле он установлен в целевом окружении + +Надёжнее: +- default distro использовать только как initial heuristic +- после явного wizard choice или успешного install persist'ить `preferred WSL distro` +- дальше status/runtime сначала пробуют persisted distro, а уже потом fallback на default + +### ⚠️ Windows WSL runtime должен явно выбрать binary model для `claude` + +Это один из самых недооценённых технических рисков. + +Проблема: +- текущий `ClaudeBinaryResolver` на Windows может вернуть разные executable shapes: `.exe`, `.cmd`, `.bat`, `.com` +- runtime через WSL tmux не может бездумно считать, что любой найденный host binary одинаково пригоден +- `.cmd` / shell-wrapper path особенно рискованны из-за quoting, shell fallback и cleanup semantics + +Дополнительный факт из Microsoft docs: +- Windows tools from WSL must include executable extension +- batch scripts are not direct executables there and need `cmd.exe /C ...` +Источник: [Working across Windows and Linux file systems](https://learn.microsoft.com/en-us/windows/wsl/filesystems) + +Практический вывод для v1 Windows runtime follow-up: +- если идём через WSL tmux + Windows interop, prefer native Windows `.exe` binary +- не считать `.cmd`/`.bat` автоматически runtime-ready без отдельного validation path +- Linux binary inside WSL - это отдельная модель с другими config/auth consequences, её нельзя смешивать с host-binary path без явного решения + +## 3.2 Дополнительные design constraints после IOF + +- Не использовать `pkexec` в v1 как основной Linux privilege path + - слишком много variability по desktop environment, polkit agent, headless режимам + - для нашего desktop app более предсказуемый путь это `PTY + sudo` +- Не строить installer на существующем `PtyTerminalService`/`TerminalModal` без доработки + - текущий `PtyTerminalService` стримит output только в renderer и не даёт main-side control surface + - текущий `TerminalModal` сам спавнит процесс и потому не подходит как source of truth для service-owned installer state +- Не показывать raw terminal output без ограничения размера + - нужен ring buffer + - нужен redaction policy для чувствительных строк +- Не запускать одновременно `auto-install` и `manual terminal` для одной и той же установки +- Не собирать install команды строковой конкатенацией + - в плане должны фигурировать `command + args + env + requiresPty` + - shell string допустим только для заранее заданного inner `sh -lc`, собранного из наших шаблонов, а не из пользовательского ввода +- Для Windows WSL core install не обещать live stdout/stderr как обязательную возможность + - elevated child process может открываться во внешнем окне/UAC flow + - progress для этого шага должен быть step-based, а не "мы всегда покажем все логи" +- Для Windows elevated steps нужен отдельный execution contract + - `pending_external_elevation` + - `waiting_for_external_step` + - `external_step_finished` + - `external_step_failed` + - это не то же самое, что PTY-backed install на Linux/WSL userland +- Не требовать WSL 2 там, где `tmux` уже реально работает в WSL 1 + - для продукта важнее usable tmux path, чем конкретная WSL version label +- Не оставлять в `TmuxStatus` двусмысленное поле `available` + - после добавления WSL-aware path нужен явный раздел `host` / `wsl` / `effective` +- Все platform-specific решения должны жить в main-process service layer, не в React + +## 4. Текущее состояние кодовой базы + +### Feature-стандарт, который надо учитывать + +- authoritative document для этой задачи - `docs/FEATURE_ARCHITECTURE_STANDARD.md` +- он задаёт canonical template для medium/large feature: + - `src/features//contracts` + - `src/features//core/domain` + - `src/features//core/application` + - `src/features//main/composition` + - `src/features//main/adapters/input` + - `src/features//main/adapters/output` + - `src/features//main/infrastructure` + - `src/features//preload` + - `src/features//renderer` +- эта задача точно подпадает под full slice, потому что: + - пересекает больше одной process boundary + - вводит свой transport bridge + - вводит свой use case и policy logic + - имеет main/preload/renderer orchestration +- `src/renderer/features/CLAUDE.md` можно использовать только как локальную подсказку для внутренних renderer-паттернов, но не как главный стандарт этой фичи +- structural reference implementation для этой фичи - `src/features/recent-projects` + - public entrypoints + - composition-root wiring + - preload bridge pattern + - renderer dumb UI + hook orchestration + +### Что уже есть + +- `src/main/ipc/tmux.ts` + - простой `getStatus()` + - cache TTL + - probe через `tmux -V` +- `src/shared/types/tmux.ts` + - минимальный `TmuxStatus` +- `src/renderer/components/dashboard/TmuxStatusBanner.tsx` + - баннер на дашборде + - OS-specific manual commands +- `src/main/services/infrastructure/CliInstallerService.ts` + - хороший референс по installer progress events + - `setMainWindow()` + - `sendProgress()` + - `checking/downloading/verifying/installing/completed/error` +- `src/main/services/infrastructure/PtyTerminalService.ts` + - PTY для interactive terminal workflows +- `src/renderer/components/terminal/TerminalModal.tsx` + - уже есть UI для живого терминала и status footer +- `src/main/ipc/config.ts` + - уже есть полезные WSL helpers: + - candidate resolution для `wsl.exe` + - UTF-16-aware decode WSL output +- `src/main/utils/pathDecoder.ts` + - уже есть path translation utility для WSL mount paths + +### Где уже есть ограничения + +- `src/main/services/team/runtimeTeammateMode.ts` + - на `win32` сейчас всегда возвращает `forceProcessTeammates: false` +- `src/main/services/team/TeamProvisioningService.ts` + - Windows-пропуски по `ps`-based live process detection + - `kill-pane` работает только через host `tmux` +- `src/main/services/infrastructure/PtyTerminalService.ts` + - сейчас умеет только `spawn/write/resize/kill` + - data/exit события уходят напрямую в renderer, но не в installer service + - сам `node-pty` там optional native addon, так что installer не должен предполагать его наличие без capability check +- `src/renderer/components/terminal/TerminalModal.tsx` + - это self-managed terminal UI, который сам запускает PTY + - attach к уже идущему installer session сейчас не поддерживается + +### Что можно переиспользовать из `agent_teams_orchestrator` + +- package manager resolver: + - `src/utils/nativeInstaller/packageManagers.ts` +- WSL-aware tmux execution: + - `src/utils/tmuxSocket.ts` + +Это не код "скопировать как есть", а скорее reference implementation и source of ideas. + +## 5. Главный продуктовый вывод + +### Что можно обещать пользователю честно + +#### macOS / Linux + +Можно обещать: +- установка из UI +- понятный progress/status +- проверка результата +- fallback на manual install + +#### Windows + +Можно обещать: +- удобный guided WSL setup +- честный status по каждому шагу +- понятный "что делать дальше" + +Нельзя честно обещать: +- silent one-click install без admin/reboot/setup user +- что это сработает на каждом корпоративном ноуте без вмешательства IT + +## 6. Рекомендуемая архитектура + +### 6.1 Новые сущности внутри feature slice + +Добавить: + +- `src/features/tmux-installer/core/application/use-cases/GetTmuxStatusUseCase.ts` +- `src/features/tmux-installer/core/application/use-cases/InstallTmuxUseCase.ts` +- `src/features/tmux-installer/core/application/use-cases/CancelTmuxInstallUseCase.ts` +- `src/features/tmux-installer/core/application/use-cases/GetTmuxInstallerSnapshotUseCase.ts` +- `src/features/tmux-installer/core/application/ports/TmuxStatusSourcePort.ts` +- `src/features/tmux-installer/core/application/ports/TmuxInstallerRunnerPort.ts` +- `src/features/tmux-installer/core/application/ports/TmuxInstallerSnapshotPort.ts` +- `src/features/tmux-installer/main/composition/createTmuxInstallerFeature.ts` +- `src/features/tmux-installer/main/adapters/input/ipc/registerTmuxInstallerIpc.ts` +- `src/features/tmux-installer/main/adapters/output/presenters/TmuxInstallerProgressPresenter.ts` +- `src/features/tmux-installer/main/adapters/output/sources/TmuxStatusSourceAdapter.ts` +- `src/features/tmux-installer/main/adapters/output/runtime/TmuxInstallerRunnerAdapter.ts` +- `src/features/tmux-installer/main/infrastructure/installer/TmuxInstallStrategyResolver.ts` +- `src/features/tmux-installer/main/infrastructure/installer/TmuxCommandRunner.ts` +- `src/features/tmux-installer/main/infrastructure/platform/TmuxPlatformResolver.ts` +- `src/features/tmux-installer/main/infrastructure/platform/TmuxPackageManagerResolver.ts` +- `src/features/tmux-installer/main/infrastructure/wsl/TmuxWslService.ts` +- `src/features/tmux-installer/main/infrastructure/wsl/WindowsElevatedStepRunner.ts` +- `src/features/tmux-installer/main/infrastructure/wsl/TmuxWslPathBridge.ts` +- `src/features/tmux-installer/main/infrastructure/wsl/TmuxWslPreferenceStore.ts` + +### 6.2 Canonical feature slice по стандарту + +Для `tmux-installer` нужен full feature slice, а не renderer-local feature. + +Рекомендуемая структура: + +```text +src/features/tmux-installer/ + contracts/ + api.ts + channels.ts + dto.ts + index.ts + core/ + domain/ + models/ + policies/ + application/ + models/ + ports/ + use-cases/ + main/ + index.ts + composition/ + createTmuxInstallerFeature.ts + adapters/ + input/ + ipc/ + registerTmuxInstallerIpc.ts + output/ + presenters/ + sources/ + runtime/ + infrastructure/ + installer/ + platform/ + wsl/ + preload/ + createTmuxInstallerBridge.ts + index.ts + renderer/ + index.ts + adapters/ + hooks/ + ui/ + utils/ +``` + +Почему именно full slice: +- feature пересекает `main -> preload -> renderer` +- у feature есть собственные transport contracts +- у feature есть собственная orchestration logic и policy layer +- у feature есть platform-specific runtime/infrastructure детали, которые нельзя размазывать по app shell + +### 6.2.1 Slice responsibilities + +`contracts/` +- DTO +- API fragment types +- IPC channel constants +- без store access, Electron-specific wiring и business orchestration + +`core/domain/` +- чистые business rules +- capability classification +- error normalization policy +- manual hint selection rules +- completion / retry / fallback invariants +- без Electron, child_process, PTY, package manager calls + +`core/application/` +- use cases +- source/output/cache ports +- response models +- orchestration contracts +- без Electron, React, Zustand, child process modules + +`main/composition/` +- composition root feature +- wiring use cases, adapters, infrastructure +- небольшой facade для app shell registration + +`main/adapters/input/` +- IPC handlers +- перевод transport input в use case calls + +`main/adapters/output/` +- presenters +- adapters для source/cache/runtime ports +- тонкий слой между infra и core/application + +`main/infrastructure/` +- `PtyTerminalService` integration +- OS/package manager specifics +- WSL detection +- elevated step runner +- binary probing +- path bridge + +`preload/` +- thin bridge +- зависит от `contracts/` +- без composition logic + +`renderer/` +- dumb UI +- hooks orchestration +- adapters DTO -> view model +- небольшие pure utils + +### 6.2.2 Renderer sub-structure внутри canonical slice + +Внутри `src/features/tmux-installer/renderer/`: + +```text +renderer/ + index.ts + adapters/ + TmuxInstallerBannerAdapter.ts + hooks/ + useTmuxInstallerBanner.ts + ui/ + TmuxInstallerBannerView.tsx + utils/ + formatTmuxInstallerText.ts +``` + +Правила: +- `renderer/ui` не импортирует `@renderer/api`, `@renderer/store`, `@main/*`, Electron APIs +- hooks не ходят в `window.electronAPI` напрямую +- transport usage идёт через shared renderer API abstraction +- `TmuxStatusBanner.tsx` остаётся compatibility wrapper и импортирует только `@features/tmux-installer/renderer` + +### 6.2.3 Жёсткие правила соответствия standard doc + +Обязательные правила: +- app shell и другие фичи импортируют только: + - `@features/tmux-installer/contracts` + - `@features/tmux-installer/main` + - `@features/tmux-installer/preload` + - `@features/tmux-installer/renderer` +- не делать deep-import feature internals снаружи +- `core/domain` side-effect free +- `core/application` не знает про Electron/React/Zustand/child processes +- `renderer/ui` dumb and presentational +- transport bridge тонкий и живёт в `preload/` +- architecture ориентируется на browser/Tauri-friendly direction + +Public entrypoints: + +```ts +// src/features/tmux-installer/contracts/index.ts +export * from './api'; +export * from './channels'; +export * from './dto'; + +// src/features/tmux-installer/main/index.ts +export { createTmuxInstallerFeature } from './composition/createTmuxInstallerFeature'; +export { registerTmuxInstallerIpc } from './adapters/input/ipc/registerTmuxInstallerIpc'; + +// src/features/tmux-installer/renderer/index.ts +export { TmuxInstallerBannerView } from './ui/TmuxInstallerBannerView'; + +// src/features/tmux-installer/preload/index.ts +export { createTmuxInstallerBridge } from './createTmuxInstallerBridge'; +``` + +Чего не делать: + +```ts +// не импортировать снаружи +import { TmuxInstallerBannerAdapter } from '@features/tmux-installer/renderer/adapters/TmuxInstallerBannerAdapter'; +import { InstallTmuxUseCase } from '@features/tmux-installer/core/application/use-cases/InstallTmuxUseCase'; +``` + +App-shell shim rules: +- если трогаем `src/main/ipc/tmux.ts`, он должен остаться только registration/compatibility shim +- если трогаем `src/preload/index.ts`, он должен только подключать `@features/tmux-installer/preload` +- никакой business logic не должна жить в этих shared shell файлах + +### 6.2.4 Renderer error model по standard + +Внутри feature нужен typed error layer, а не просто `string | null` everywhere. + +Рекомендуемо: + +```ts +export class TmuxInstallerFeatureError extends Error { + constructor( + readonly code: + | 'ADAPTER_ERROR' + | 'SNAPSHOT_ERROR' + | 'PROGRESS_STREAM_ERROR' + | 'INVALID_STATUS' + | 'INVALID_PROGRESS', + message: string, + readonly cause?: unknown + ) { + super(message); + this.name = 'TmuxInstallerFeatureError'; + } +} +``` + +Где: +- adapter ловит IPC/external shape проблемы и заворачивает их в typed error +- domain service мапит их в user-facing banner state +- UI не работает напрямую с exception shapes + +### 6.3 IPC и contracts + +Расширить `tmux` API: + +```ts +export interface TmuxAPI { + getStatus: () => Promise; + install: () => Promise; + invalidateStatus: () => Promise; + cancelInstall: () => Promise; + onProgress: (cb: (event: unknown, data: TmuxInstallerProgress) => void) => () => void; +} +``` + +Новые IPC channels: + +```ts +export const TMUX_GET_STATUS = 'tmux:getStatus'; +export const TMUX_INSTALL = 'tmux:install'; +export const TMUX_INVALIDATE_STATUS = 'tmux:invalidateStatus'; +export const TMUX_CANCEL_INSTALL = 'tmux:cancelInstall'; +export const TMUX_INSTALLER_PROGRESS = 'tmux:progress'; +``` + +⚠️ Для feature-based renderer architecture этого недостаточно. Нужен ещё snapshot endpoint, чтобы feature мог восстановиться после remount/reload и не зависеть от того, был ли progress event пойман вживую. + +Добавить: + +```ts +export const TMUX_GET_INSTALLER_SNAPSHOT = 'tmux:getInstallerSnapshot'; + +export interface TmuxAPI { + getInstallerSnapshot: () => Promise; +} +``` + +Где: +- main process держит актуальный installer state как source of truth +- renderer slice при mount сначала делает `getStatus()` + `getInstallerSnapshot()` +- затем подписывается на live progress + +Это должно жить в `src/features/tmux-installer/contracts/`, а не в случайных shared renderer types. + +### 6.4 Shared types + +Рекомендуемое расширение feature contracts/dto в `src/features/tmux-installer/contracts/`: + +```ts +export type TmuxInstallStrategy = + | 'homebrew' + | 'macports' + | 'apt' + | 'dnf' + | 'yum' + | 'zypper' + | 'pacman' + | 'wsl' + | 'manual' + | 'unknown'; + +export type TmuxInstallerPhase = + | 'idle' + | 'checking' + | 'preparing' + | 'requesting_privileges' + | 'pending_external_elevation' + | 'waiting_for_external_step' + | 'installing' + | 'verifying' + | 'needs_restart' + | 'needs_manual_step' + | 'completed' + | 'error' + | 'cancelled'; + +export interface TmuxInstallHint { + title: string; + description: string; + command?: string; + url?: string; +} + +export interface TmuxAutoInstallCapability { + supported: boolean; + strategy: TmuxInstallStrategy; + packageManagerLabel?: string | null; + requiresTerminalInput: boolean; + requiresAdmin: boolean; + requiresRestart: boolean; + mayOpenExternalWindow?: boolean; + reasonIfUnsupported?: string | null; + manualHints: TmuxInstallHint[]; +} + +export interface TmuxWslStatus { + wslInstalled: boolean; + rebootRequired: boolean; + distroName: string | null; + distroVersion: 1 | 2 | null; + distroBootstrapped: boolean; + innerPackageManager: TmuxInstallStrategy | null; + tmuxAvailableInsideWsl: boolean; + tmuxVersion: string | null; + tmuxBinaryPath?: string | null; + statusDetail?: string | null; +} + +export interface TmuxWslPreference { + preferredDistroName: string | null; + source: 'persisted' | 'default' | 'manual' | null; +} + +export interface TmuxBinaryProbe { + available: boolean; + version: string | null; + binaryPath: string | null; + error: string | null; +} + +export interface TmuxEffectiveAvailability { + available: boolean; + location: 'host' | 'wsl' | null; + version: string | null; + binaryPath: string | null; + runtimeReady: boolean; + detail?: string | null; +} + +export interface TmuxStatus { + platform: TmuxPlatform; + nativeSupported: boolean; + checkedAt: string; + host: TmuxBinaryProbe; + effective: TmuxEffectiveAvailability; + error: string | null; + autoInstall: TmuxAutoInstallCapability; + wsl?: TmuxWslStatus | null; + wslPreference?: TmuxWslPreference | null; +} + +export interface TmuxInstallerProgress { + type: TmuxInstallerPhase | 'status'; + detail?: string; + percent?: number; + rawChunk?: string; + error?: string; + status?: TmuxStatus; + nextManualHint?: TmuxInstallHint | null; + externalStepLabel?: string; + canRetryNow?: boolean; +} +``` + +Смысл полей: +- `host` - что доступно нативно в текущей ОС host +- `wsl` - что доступно внутри WSL на Windows +- `effective` - какой path приложение реально может использовать +- `effective.runtimeReady` важнее простого "binary found", потому что именно он отвечает на вопрос "persistent teammate path действительно можно включать" +- `wslPreference` - какой distro приложение считает целевым для tmux path и почему + +## 7. UX и state machine + +### 7.1 Принципы UX + +- Не писать "сломано", если app просто работает без `tmux` +- Не скрывать platform-specific ограничения +- Не показывать фейковый `%`, если это невозможный процент +- Всегда давать следующий шаг +- Ошибка должна отвечать на 3 вопроса: + - что не получилось + - почему это могло случиться + - что делать дальше + +### 7.2 Основные UI состояния + +- `Not installed, can auto-install` + - CTA: `Install tmux` +- `Installing` + - шаги + - status detail + - optional raw logs + - если `mayOpenExternalWindow === true`, явно предупреждать про отдельное admin/system window +- `Waiting for external admin step` + - не показывать пустой терминал + - показывать понятный текст, что Windows мог открыть отдельное elevated окно + - CTA: `Re-check` +- `Needs manual step` + - CTA: `Open guide` + - CTA: `Retry` +- `Needs restart` + - CTA: `Re-check after restart` +- `Completed` + - CTA: `Re-check` +- `Error` + - human-readable error + - retry + - manual fallback + +### 7.3 Что показывать вместо fake percent + +Для package managers использовать не byte progress, а step progress: + +```ts +const STEP_WEIGHTS = { + checking: 10, + preparing: 20, + requesting_privileges: 30, + installing: 70, + verifying: 90, + completed: 100, +}; +``` + +То есть progress bar остаётся, но он отражает phase progression, а не сеть. + +Это честно и UX-wise полезно. + +## 8. Платформенная матрица + +## 8.1 macOS + +### Стратегия + +Порядок: +1. Если `tmux` уже есть - успех, install не нужен +2. Если найден `brew` - использовать Homebrew +3. Иначе если найден `port` - использовать MacPorts +4. Иначе manual fallback + +### Почему так + +- `brew install tmux` - лучший UX путь для macOS +- MacPorts useful fallback, но более niche +- автоматическая установка Homebrew сама по себе слишком тяжёлая и рискованная для v1 + +### Команды + +#### Homebrew + +```bash +brew install tmux +``` + +#### MacPorts + +```bash +sudo port install tmux +``` + +### Реализация + +- использовать shell-aware PATH resolution +- проверять `brew` не только в текущем `PATH`, но и в common prefixes: + - `/opt/homebrew/bin/brew` + - `/usr/local/bin/brew` +- `brew install tmux` можно запускать как обычный child process со streaming stdout/stderr +- не вводить `HOMEBREW_NO_AUTO_UPDATE=1` как default optimisation без отдельной валидации + - это может ускорить UX, но также меняет expected brew behavior + - если захотим это делать, лучше отдельным tiny spike и только с fallback probe после install +- `port install tmux` запускать через PTY, потому что возможен пароль +- если `brew` install вдруг начинает требовать interactive input или ведёт себя нестабильно через pipes, разрешён fallback на PTY и для `brew` + +### Edge cases + +- GUI app стартанул не из shell, `brew` не в PATH +- на Intel/macOS старый prefix +- `brew` есть, но formula tap сломан +- bottle download не удался, formula уходит в source build +- `xcode-select` / CLT не установлены +- MacPorts есть, но пользователь отменил `sudo` +- и `brew`, и `port` есть одновременно +- `brew` установлен, но prefix permissions повреждены +- приложение запущено без полного login-shell PATH, но `brew` реально установлен + +### Решение по приоритету + +Если есть и `brew`, и `port`: +- по умолчанию брать `brew` +- в detail UI можно написать `Using Homebrew (preferred)` + +## 8.2 Linux + +### Стратегия + +Порядок: +1. Если `tmux` уже есть - успех +2. Разобрать `/etc/os-release` +3. Определить distro family +4. Подтвердить наличие package manager binary +5. Сформировать install command +6. Выполнить через PTY + +### Почему PTY, а не `execFile` + +- `sudo` часто требует TTY +- пользователю может понадобиться ввести пароль +- package manager output полезен как live log +- но PTY должен быть owned main-process installer service, а не renderer modal + +### Поддерживаемые менеджеры v1 + +- Debian / Ubuntu -> `apt-get` +- Fedora / RHEL -> `dnf` +- older RHEL / CentOS -> `yum` +- openSUSE -> `zypper` +- Arch -> `pacman` + +### Явно unsupported для auto-install v1 + +- immutable rpm-ostree hosts +- transactional-update based hosts +- нестандартные embedded/container systems без нормального package DB + +Для них: +- auto-install capability = `supported: false` +- только manual guidance +- reason text должен быть явным, не generic + +### Команды + +#### Debian / Ubuntu + +```bash +sudo apt-get install -y tmux +``` + +#### Fedora / RHEL + +```bash +sudo dnf install -y tmux +``` + +#### old RHEL / CentOS + +```bash +sudo yum install -y tmux +``` + +#### openSUSE + +```bash +sudo zypper --non-interactive install tmux +``` + +#### Arch + +```bash +sudo pacman -S --needed --noconfirm tmux +``` + +### Почему именно такие флаги + +- `apt-get -y` + - Debian manpage: `-y, --yes, --assume-yes` делает install non-interactive и abort'ит на unsafe conditions вроде unauthenticated packages: [apt-get(8)](https://manpages.debian.org/bookworm/apt/apt-get.8.en.html) +- `dnf -y` + - DNF command reference: `-y, --assumeyes` автоматически отвечает yes на вопросы: [DNF Command Reference](https://dnf.readthedocs.io/en/latest/command_ref.html) +- `zypper --non-interactive install` + - SUSE docs явно рекомендуют `--non-interactive` до команды `install` для scripted usage: [SUSE zypper docs](https://documentation.suse.com/sles/15-SP5/html/SLES-all/cha-sw-cl.html) +- `pacman --noconfirm` + - Arch manual: bypasses confirmation prompts и предназначен для scripted usage; `--needed` не переустанавливает уже актуальный target: [pacman(8)](https://man.archlinux.org/man/pacman.8.en) + +### Почему `apt-get`, а не `apt` + +Официальная tmux wiki в user-facing тексте показывает `apt install tmux`, но для scripted/background workflow стабильнее использовать `apt-get`. + +Это design inference, а не quote from source. + +### Почему не `pkexec` + +Хотя `pkexec` теоретически даёт более desktop-native privilege prompt, в v1 он хуже по надёжности: +- зависит от polkit integration в системе +- отличается по поведению между DE/WM +- в некоторых средах отсутствует или ведёт себя нестабильно + +Поэтому default path: +- `PTY + sudo` + +### Почему не делаем `apt-get update` always-on + +Лучший default flow: + +```bash +sudo apt-get install -y tmux +``` + +Fallback retry only if needed: + +```bash +sudo apt-get update && sudo apt-get install -y tmux +``` + +Причина: +- меньше network surface area +- быстрее happy-path +- проще читать логи + +### Linux edge cases + +- пользователь уже root -> не добавлять `sudo` +- `sudo` не установлен +- пользователь не в sudoers +- package manager lock: + - `apt` lock + - `pacman` lock +- repository metadata stale +- offline network +- unsupported distro family +- host/container environment без нормального package database +- `tmux` установился, но verification через `tmux -V` всё равно не проходит из-за PATH/env +- immutable distro, где host package install не должен быть auto path +- `sudo` требует TTY password prompt, но пользователь закрывает modal +- `pacman` keyring / mirror init issues +- `dnf` metadata or repo config corruption +- `zypper` registration/repo state issues на SUSE +- `yum` / enterprise repo family, где `tmux` отсутствует в подключённых repo +- `zypper` interactive repo/license conditions, которые не должны auto-accept'иться молча + +### Ошибки, которые нужно распознавать отдельно + +- permission denied / authentication failure +- package manager locked +- package not found +- network / mirror resolution failed +- cancelled by user +- immutable host unsupported +- package manager present, but repository configuration invalid +- repository missing required channel / EPEL-like repo not enabled + +## 8.3 Windows + +### Главная продуктовая позиция + +Windows в v1 не должен притворяться нативной `tmux` платформой. + +Путь: +- `WSL wizard` +- затем установка `tmux` внутри WSL +- затем re-check + +### Важный architectural caveat + +⚠️ Пока runtime Windows не станет WSL-aware, этот wizard даст только "prepared environment", но не реальный runtime win. + +Поэтому план на Windows нужно делить на два слоя: + +#### Layer A + +Installer / wizard / detection / guidance + +#### Layer B + +Runtime enablement: +- WSL-aware `tmux` detection +- WSL-aware teammate mode decision +- WSL-aware pane/session ops + +### Рекомендуемый flow + +#### Шаг 1. Проверка WSL core и distro state + +Пробуем: + +```powershell +wsl --status +``` + +Если WSL не установлен: +- показываем CTA `Install WSL` +- detail: нужен admin и, возможно, restart + +Если `wsl --status` itself unsupported on older build: +- не классифицировать это как обычный "WSL missing" +- это отдельный capability signal +- дальше либо fallback probe через `wsl --list --verbose`, либо сразу manual Microsoft guidance для older Windows path + +Если WSL установлен, но дистрибутива ещё нет: +- это отдельное состояние, не путать с `WSL missing` +- дальше нужен install конкретного distro, а не повторная "общая" диагностика + +Дополнительное правило: +- не блокировать сценарий только потому, что distro на WSL 1 +- если inner `tmux` запускается и будущий runtime smoke test проходит, это usable path + +#### Шаг 2. Установка WSL + +Если WSL отсутствует полностью, рекомендуемый путь: + +```powershell +wsl --install --no-distribution +``` + +Если хотим объединённый flow "WSL + сразу Ubuntu": + +```powershell +wsl --install -d Ubuntu --no-launch +``` + +Если install hangs или Store path problematic, дать fallback hint: + +```powershell +wsl --install --web-download -d Ubuntu +``` + +Флаги тут выбраны не случайно: +- `--no-launch` documented in Microsoft basic commands as "install distro but do not launch it automatically": [Basic commands for WSL](https://learn.microsoft.com/en-us/windows/wsl/basic-commands) +- `--web-download` documented in Microsoft install guide как fallback, если install hangs at `0.0%`: [Install WSL](https://learn.microsoft.com/en-us/windows/wsl/install) +- `--no-distribution` documented in Microsoft basic commands for the case where WSL itself is not installed and you want to skip distro install during the core setup: [Basic commands for WSL](https://learn.microsoft.com/en-us/windows/wsl/basic-commands) + +⚠️ Самое важное ограничение этого шага: +- `wsl --install` обычно требует elevation +- из обычного Electron process нельзя обещать, что мы всегда тихо запустим elevated child и будем стримить его live output назад в UI + +Поэтому для v1 надёжный UX такой: +- step-based progress внутри баннера +- явный текст `An administrator window may open` +- если нужен внешний elevated PowerShell, это нормально +- после завершения шага всегда делать fresh re-check, а не верить только exit code внешнего окна + +Почему это лучше для нашего плана: +- у нас уже есть отдельные UX steps `Install WSL` и `Install Ubuntu` +- `--no-distribution` лучше совпадает с этой state machine +- меньше путаницы, когда WSL core установился, а distro ещё нет + +Предпочтительный execution model для этого шага: +1. app создаёт temp marker directory +2. app запускает elevated helper через PowerShell `Start-Process -Verb RunAs -Wait` +3. helper выполняет `wsl --install ...` +4. helper пишет короткий JSON result file в temp directory +5. app читает result file, но всё равно делает fresh probe через `wsl --status` / `wsl --list` + +Это лучше, чем надеяться на redirected stdout/stderr, который в `RunAs` flow ненадёжен. + +Implementation note: +- helper лучше запускать как temp `.ps1` file, а не как огромную inline command string +- так меньше quoting bugs и проще сохранять diagnostics/result file + +### Почему `--no-launch` + +Это даёт нам больше контроля над wizard flow: +- сначала установить +- потом отдельно проверить reboot requirement +- потом отдельно вести пользователя в first-launch/bootstrap + +### Шаг 3. Restart detection + +После WSL install: +- если `wsl --status` / `wsl -l -v` всё ещё не готово +- или Windows сообщает, что optional components activated but reboot required + +UI state: +- `needs_restart` +- кнопка `Re-check after restart` + +Отдельный edge: +- для older Windows builds, где `wsl --install` вообще не поддерживается, сразу переводить в manual guidance на official Microsoft manual install page, а не пытаться эмулировать unsupported flow: [Manual installation steps for older versions of WSL](https://learn.microsoft.com/en-us/windows/wsl/install-manual) + +### Шаг 3.5. Установка Linux distro, если WSL core уже есть + +Если `wsl --status` успешен, но список дистрибутивов пуст: +- это не повод повторять full WSL core install +- дальше нужен именно install distro + +Рекомендуемый путь: + +```powershell +wsl --list --online +wsl --install -d Ubuntu --no-launch +``` + +Если online catalog недоступен или Store path заблокирован: +- сразу дать manual Microsoft guidance +- не обещать, что приложение само обойдёт Store/corporate restrictions + +Продуктовое правило: +- `Install WSL` и `Install Ubuntu` должны быть разными step labels в UI +- это уменьшает путаницу при поддержке и в error analytics + +Design rule: +- `Install Ubuntu` тоже не надо моделировать как гарантированно одинаковый in-app step на всех Windows машинах +- где-то он пройдёт как normal command path, где-то уйдёт в Store / external flow, где-то упрётся в policy +- поэтому и этот шаг должен завершаться только fresh probe'ом по факту появившегося distro, а не optimistic success UI + +### Шаг 4. Distro bootstrap + +Даже когда WSL установлен, пользователь может ещё не пройти first-launch distro setup. + +Признаки: +- distro установлена, но inner command падает +- first launch требует initial decompression или user creation + +Решение: +- отдельный шаг `Complete Linux distro setup` +- открываем пользователю команду: + +```powershell +wsl -d +``` + +После этого пользователь: +- ждёт распаковку +- создаёт Linux username/password + +Затем возвращается в app и нажимает `Re-check`. + +### Почему bootstrap должен быть отдельным шагом, а не скрытой автоматикой + +Это более надёжно и честно: +- пользователь видит создание Linux account/password +- меньше магии +- меньше platform-specific assumptions про state дистрибутива +- проще поддержка и диагностика + +### Шаг 4.1 Distro selection policy + +Если default distro уже есть: +- используем её + +Если distro нет: +- предлагаем `Ubuntu` как recommended default +- но в коде не хардкодим будущие команды на имя `Ubuntu` +- реальное имя выбранного/установленного дистрибутива всегда берём из `wsl --list --verbose` / `wsl --list --quiet` + +Лучший practical rule: +- для списка имён дистрибутивов prefer `wsl --list --quiet` +- `--list --verbose` использовать в основном для diagnostics и best-effort `distroVersion` +- это снижает зависимость от локализованных header/state строк в output. Microsoft docs отдельно показывают `--quiet` как режим "show only distribution names": [Basic commands for WSL](https://learn.microsoft.com/en-us/windows/wsl/basic-commands) +- если нужно понять именно default distro, лучше опираться на stable markers вроде `*` prefix / persisted preference, а не на локализованные текстовые суффиксы + +Если их несколько: +- в v1 можно брать default distro +- если default не определён, нужна явная UI selection или forced recommendation + +Нельзя молча выбирать произвольную distro, если их несколько и default неочевиден. + +Если default distro есть, но его family unsupported для нашего auto-install v1: +- не пытаться "угадать" команды +- либо даём manual guidance для этого дистрибутива +- либо предлагаем отдельно установить supported distro, например Ubuntu +- не меняем default distro молча + +Важное уточнение после IOF: +- default distro нужен только как first guess +- после user choice или успешной установки приложение должно persist'ить `preferred distro` +- дальнейшие `status`, `verify` и будущий runtime follow-up сначала используют persisted distro +- если persisted distro исчезла из `wsl --list`, это отдельный recoverable state: + - clear stale preference + - показать понятный re-select / re-install flow + +### Шаг 5. Установка tmux внутри WSL + +Когда distro доступна, дальше стратегия как на Linux, но команды выполняются внутри WSL. + +Надёжный v1 default: +- запускать install внутри `wsl.exe` через PTY +- использовать Linux-user path с `sudo` +- не делать hidden root install по умолчанию + +Например для выбранного distro: + +```powershell +wsl --distribution -- sh -lc "sudo apt-get install -y tmux" +``` + +Если distro не Debian-based: +- читаем `/etc/os-release` внутри WSL +- подбираем inner package manager как для Linux +- если family unsupported, останавливаем auto path и даём manual guidance вместо рискованной "магии" + +Нюанс UX: +- пароль здесь обычно не Windows admin password, а Linux password выбранного WSL user +- это нужно явно подписать в UI, иначе пользователь будет думать, что приложение просит "не тот пароль" + +Нюанс надёжности: +- если делаем retry вроде `apt-get update && apt-get install`, лучше держать это в том же PTY session +- так мы не теряем `sudo` timestamp и не заставляем пользователя вводить пароль дважды без причины + +Optional future optimization: +- после явной проверки bootstrap-ready state можно отдельно исследовать `--user root` +- но это не должно быть основным путём v1 + +### Шаг 6. Verification + +```powershell +wsl --distribution -- sh -lc "tmux -V" +``` + +Но для надёжности этого мало. + +Рекомендуемая verification ladder: +1. `tmux -V` +2. безопасный smoke test на отдельном socket name, чтобы не трогать пользовательские tmux sessions + +Например: + +```powershell +wsl --distribution -- sh -lc "tmux -L codex-smoke -f /dev/null new-session -d true && tmux -L codex-smoke kill-server" +``` + +Именно smoke test лучше отвечает на вопрос "runtime path реально живой", а не только "binary существует". + +Для Windows follow-up это особенно важно: +- smoke test должен по возможности использовать тот же adapter path, который потом будет использовать runtime +- иначе можно случайно проверить "tmux работает в интерактивном `wsl sh -lc`", но не проверить "наш main-process adapter реально умеет работать с ним стабильно" + +### Windows edge cases + +- user denied UAC prompt +- WSL install succeeded partially +- reboot required +- no distro installed +- distro exists but not bootstrapped +- imported distro без launcher quirks +- default distro не Ubuntu +- inner distro не Debian-based +- inner distro unsupported для v1 auto-install +- WSL networking / store download issues +- corporate policy blocks virtualization +- `wsl.exe` есть, но kernel/update broken +- WSL установлен, но default distro не выбрана +- `wsl --install` partially succeeded, но distro не зарегистрировалась +- пользователь завершил bootstrap partially и закрыл окно +- Windows build слишком старый для `wsl --install` +- app не elevated и WSL core install ушёл во внешний admin window без live stdout +- 32-bit helper process path quirks для `wsl.exe`; defensive note из Microsoft docs - при необходимости `C:\\Windows\\Sysnative\\wsl.exe --command`: [Basic commands for WSL](https://learn.microsoft.com/en-us/windows/wsl/basic-commands) +- locale-sensitive WSL output, если где-то случайно полагаться на human-readable headers вместо quiet list / stable markers + +## 9. Самое важное решение по Windows runtime + +Если хотим сделать Windows path не "paper feature", а реально полезным, нужно обязательно сделать follow-up: + +### 9.1 `tmux:getStatus` на Windows должен стать WSL-aware + +Сейчас status проверяет host binary. Это не подходит. + +Нужно: +- сначала probe host tmux +- если `win32` и host tmux нет: + - resolve `wsl.exe` через candidate list, а не слепо через `'wsl'` + - decode output defensively, потому что `wsl.exe` может возвращать UTF-16LE / mixed encoding + - probe `wsl --status` + - probe persisted/default/selected distro + - probe inner `tmux -V` +- собирать не один boolean, а нормальный `host / wsl / effective` snapshot +- не считать WSL 1 автоматическим fail, если smoke test на `tmux` проходит + +У нас уже есть полезные reference pieces в кодовой базе: +- `src/main/ipc/config.ts` + - candidate paths для `wsl.exe` (`System32`, `Sysnative`, fallback `wsl.exe`) + - UTF-16-aware decoding output + - `listWslDistros()` с несколькими command variants (`--list --quiet`, `-l -q`, `-l`) + +Их нужно не дублировать ad-hoc, а переиспользовать или вынести в общий helper. + +Ещё одно правило: +- `distroVersion` полезен как diagnostic field, но не должен быть gating factor сам по себе +- gating factor это usable adapter path + smoke test, а не просто цифра `1` или `2` + +### 9.2 `resolveDesktopTeammateModeDecision()` на Windows + +Сейчас: + +```ts +if (process.platform === 'win32') { + return { + injectedTeammateMode: null, + forceProcessTeammates: false, + }; +} +``` + +Для полноценного Windows support это надо заменить на WSL-aware decision path. + +### 9.3 Team runtime ops + +Нужно отдельно решить: +- как запускать `tmux` команды через `wsl -e tmux ...` +- как резолвить `wsl.exe` и его output decoding consistently во всех runtime ops +- как хранить pane ids / target ids +- как делать cleanup +- как переводить Windows `cwd` в WSL path и обратно +- как не ломаться на UNC paths вида `\\\\wsl.localhost\\\\...` +- как пинить `WSL_INTEROP` для долгоживущего tmux server +- как persist'ить целевой distro и не ломаться, если default distro изменился +- какой `claude` binary model считается supported внутри WSL tmux runtime + +Это не детали, а критичные runtime requirements. + +Отдельное правило: +- прямые tmux runtime-команды на Windows должны идти через direct exec path (`wsl -e tmux ...` / `wsl --exec tmux ...`), а не через shell wrapper +- shell wrapper оставляем только там, где реально нужен `sh -lc`, например для package-manager install steps + +Почему это важно: +- tmux format strings вроде `#{socket_path}` содержат `#` +- если команду отдать login shell'у, shell может интерпретировать это как comment и сломать вызов +- этот баг уже отражён в orchestrator reference + +#### 9.3.1 Path translation + +Если team runtime на Windows будет реально работать через WSL tmux, то `request.cwd` и любые project-related paths нельзя просто пробрасывать как есть. + +Нужно: +- для Windows host paths делать conversion в WSL path +- для UNC WSL paths проверять совпадение distro +- иметь fallback manual conversion, если `wslpath` unavailable + +Reference ideas уже есть в: +- `agent_teams_orchestrator/src/utils/idePathConversion.ts` +- `src/main/utils/pathDecoder.ts` + +Без этого можно получить очень неприятные полубаги: +- tmux стартует, но в неправильном `cwd` +- runtime работает только для `C:\\...`, но ломается на `\\\\wsl.localhost\\...` +- путь формально передан, но внутри WSL не существует + +#### 9.3.1.a Cross-filesystem performance and case semantics + +Это отдельный риск, который нельзя путать с "путь существует". + +По Microsoft docs: +- при работе из Linux command line fastest path - хранить файлы в WSL filesystem +- работа по `/mnt/c/...` возможна, но медленнее +- Windows и Linux по-разному ведут себя по case sensitivity +Источник: [Working across Windows and Linux file systems](https://learn.microsoft.com/en-us/windows/wsl/filesystems) + +Следствие для плана: +- `translatedCwdExists === true` ещё не означает "runtime path хороший" +- если project cwd живёт на Windows filesystem и внутри WSL превращается в `/mnt/c/...`, runtime может быть functional, но slower +- это не должно блокировать v1, но это должно попадать: + - в diagnostics + - в optional UX hint вроде `For best performance on Windows + WSL, store the project inside the WSL filesystem` + +Ещё один subtle point: +- Windows file systems часто case-insensitive +- Linux file systems case-sensitive +- любые runtime assumptions на равенство путей должны быть нормализованы очень аккуратно + +Дополнительный факт: +- Microsoft документирует `WSLENV` как bridge для env vars между Windows и WSL, включая path translation flags +Источник: [Working across Windows and Linux file systems](https://learn.microsoft.com/en-us/windows/wsl/filesystems) + +Практический вывод: +- `WSLENV` можно рассматривать как future optimisation/helper +- но для v1 лучше не делать его primary correctness layer +- path bridge должен оставаться явным и testable в нашем коде + +#### 9.3.2 `WSL_INTEROP` pinning + +Самый скрытый и опасный runtime bug сейчас подсвечен в orchestrator reference: +- tmux server, стартовавший через краткоживущий `wsl.exe`, может унаследовать нестабильный interop socket +- после detach/exit spawning `wsl.exe` interop перестаёт корректно работать для дальнейших Win32 launches из tmux + +Reference: +- `agent_teams_orchestrator/src/utils/tmuxSocket.ts` + +Практический вывод для плана: +- Windows runtime follow-up должен явно закладывать `WSL_INTEROP=/run/WSL/1_interop` при создании isolated tmux server +- и ставить его в tmux global environment для новых sessions + +Иначе можно получить очень неприятный класс багов: +- installer/verify выглядит успешным +- tmux server поднимается +- но позже реальные команды из persistent teammate path начинают падать на interop/timeouts + +#### 9.3.3 Isolated socket and `TMUX` env override + +Ещё один обязательный runtime requirement: +- приложение не должно работать в пользовательском tmux server по умолчанию +- нужен отдельный isolated socket, как в orchestrator reference + +Нужно: +- создавать свой socket name, например `claude-` или другой deterministic app-specific format +- все tmux команды гонять через `-L ` +- дочерним процессам внутри teammate runtime прокидывать правильный `TMUX` env, указывающий на наш socket +- cleanup всегда делать только по нашему socket name / server pid, не по generic tmux targets + +Иначе можно словить очень неприятный bug class: +- user уже работает в своём tmux внутри WSL +- приложение начинает управлять не своим server/session +- cleanup и `kill-pane` задевают не тот runtime + +#### 9.3.4 `claude` binary model inside WSL tmux + +Нужно принять explicit решение, что именно запускает teammate runtime внутри WSL pane: + +Вариант A - host Windows `claude.exe` через WSL interop +- 🎯 8 🛡️ 7 🧠 6 +- `150-350` строк follow-up logic +- Плюсы: + - ближе к текущей архитектуре desktop app + - reuse существующего host-side `ClaudeBinaryResolver` + - меньше шансов разъехаться по auth/config flows +- Риски: + - нужно жёстко prefer `.exe` + - `.cmd`/`.bat` wrappers нельзя считать автоматически эквивалентными + - interop/runtime smoke test должен проверять именно этот path + +Вариант B - Linux `claude` внутри WSL distro +- 🎯 5 🛡️ 6 🧠 8 +- `400-900` строк follow-up logic +- Плюсы: + - более "нативная" Linux execution model внутри tmux +- Риски: + - отдельная установка binary внутри WSL + - потенциально другой auth/config root + - больше drift от текущего Windows desktop runtime + - если бинарь/скрипты лежат на mounted Windows filesystem, semantics file permissions в WSL становятся отдельным источником проблем: [File permissions for WSL](https://learn.microsoft.com/en-us/windows/wsl/file-permissions) + +Рекомендация для v1 follow-up: +- идти через вариант A, но только если available binary это пригодный `.exe` +- если resolver на Windows дал только `.cmd`/`.bat`, не поднимать `runtimeReady=true` без отдельного validated wrapper path + +#### 9.3.5 Config/auth root semantics for Windows `claude.exe` from WSL + +Это ещё один важный слой, который легко пропустить. + +Факт из Microsoft docs: +- Windows executables, запущенные из WSL, работают как Win32 apps активного Windows user и retain WSL working directory for the most part +Источник: [Working across Windows and Linux file systems](https://learn.microsoft.com/en-us/windows/wsl/filesystems) + +Inference для нашего проекта: +- если teammate runtime внутри WSL pane запускает host `claude.exe`, нужно явно проверить, какие config/auth/session roots он использует в таком режиме +- нельзя автоматически считать, что "working dir внутри WSL" означает "и config root тоже WSL" + +Практический вывод: +- Windows runtime readiness probe должен включать не только запуск бинаря, но и sanity check на auth/config behavior тем же adapter path +- для v1 лучше держать модель консервативной: + - prefer host Windows auth/config expectations + - не объявлять runtime-ready, если probing показывает разъехавшийся config root или странный auth state + +Самый логичный reference - `agent_teams_orchestrator/src/utils/tmuxSocket.ts`. + +Если это не входит в текущую итерацию, UI/документация должны честно говорить: +- `WSL setup prepares tmux support` +- `full Windows persistent teammate runtime will be enabled in follow-up` + +## 10. Надёжность: как сделать "без багов" + +## 10.1 Installer mutex + +Нельзя запускать 2 инсталла параллельно. + +Нужно: + +```ts +if (this.installing) { + this.sendProgress({ + type: 'error', + error: 'tmux installation is already in progress', + }); + return; +} +``` + +## 10.2 Every install ends with verification + +Нельзя считать install успешным по exit code package manager alone. + +Успех только если: +- install command exit code success +- повторный status probe нашёл `tmux` +- `tmux -V` реально исполняется +- для runtime-critical enablement желательно проходит и лёгкий smoke test на isolated socket/session + +Для Windows runtime-critical enablement это лучше уточнить ещё жёстче: +- smoke test должен по возможности запускать тот же `claude` binary model, который потом реально пойдёт в teammate runtime +- тест уровня `tmux ... new-session -d true` полезен, но не доказывает, что interop launch path для реального CLI тоже живой +- если binary model это host `claude.exe` from WSL, probe должен подтверждать и auth/config sanity, а не только факт старта процесса + +## 10.2.1 Do not gate only on tmux version string + +Ещё один скрытый риск - слишком доверять `tmux -V`. + +Почему: +- package-manager версии на enterprise / LTS системах могут быть сильно старыми +- но для нас важен не номер сам по себе, а наличие конкретных runtime capabilities + +Практический rule: +- installer status может показывать `tmux -V` +- но runtime readiness должен опираться на capability probes: + - isolated socket create + - `has-session` + - `display-message -p` + - `set-environment -g` + - если Windows follow-up использует это - `new-session -e` + +Иными словами: +- `tmux` version string полезен для diagnostics +- capability contract важнее, чем числовой version gate, если мы заранее не зафиксировали минимальную supported версию + +## 10.3 No fake silent fallback + +Нельзя: +- проглотить ошибку `brew not found` +- переключиться на другой strategy без явного detail +- показать completed, если verification failed + +## 10.4 Structured error taxonomy + +Нужен нормализатор ошибок: + +```ts +type TmuxInstallErrorCode = + | 'ALREADY_INSTALLED' + | 'PACKAGE_MANAGER_NOT_FOUND' + | 'UNSUPPORTED_PLATFORM' + | 'AUTH_CANCELLED' + | 'PERMISSION_DENIED' + | 'PTY_UNAVAILABLE' + | 'NETWORK_ERROR' + | 'PACKAGE_LOCKED' + | 'PACKAGE_NOT_FOUND' + | 'RESTART_REQUIRED' + | 'WSL_NOT_INSTALLED' + | 'WSL_COMMAND_UNSUPPORTED' + | 'WSL_DISTRO_MISSING' + | 'WSL_DISTRO_NOT_READY' + | 'EXTERNAL_ELEVATION_CANCELLED' + | 'EXTERNAL_ELEVATION_FAILED' + | 'VERIFY_FAILED' + | 'USER_CANCELLED' + | 'UNKNOWN'; +``` + +И map в user-friendly messages. + +Важное правило для нормализатора: +- не полагаться только на exact English stderr text +- где возможно, использовать: + - exit code + - observable state вроде lock files / missing binary / missing distro + - несколько broad regex patterns вместо одного exact message + +Иначе: +- локализованные Linux/Windows системы будут часто падать в `UNKNOWN` +- а UI станет выглядеть "случайно нестабильным", хотя root cause классифицируемый + +## 10.5 Retry semantics + +- `Retry` должен re-run plan resolution с нуля +- перед retry: + - kill active child/pty + - invalidate status cache + - clear stale progress state + +## 10.5.1 Cache semantics during and after install + +Кэш статуса здесь легко превратить в источник ложных UI состояний. + +Правила: +- пока install active, negative cache entries нельзя считать authoritative +- после любого install step, который потенциально меняет system state, нужен explicit invalidate +- после Windows external elevated step нужен mandatory fresh probe, даже если helper сообщил success +- после app restart UI должен восстанавливаться не из in-memory installer state, а из свежего system probe + +Итог: +- installer flow должен быть idempotent +- partial install / app restart / crashed renderer не должны оставлять "залипшее installing" состояние + +## 10.5.2 Recovery after app restart or renderer crash + +Особенно важно для Windows external elevated steps. + +Правила: +- in-memory progress state не считается durable truth +- после app relaunch service сначала смотрит на system state, а не пытается "доигрывать" старый progress bar +- если остались temp marker/result files от elevated step: + - их можно использовать только как diagnostics hint + - final UI state всё равно решает fresh probe +- старые marker/result dirs нужно периодически cleanup'ить по age, чтобы не копить мусор и не читать stale outcome как новый + +## 10.6 Cancellation semantics + +Нужен `cancelInstall()`: +- убивает child/pty +- шлёт `cancelled` +- не оставляет broken installing flag + +Но для Windows external elevated step это работает иначе: +- после `Start-Process -Verb RunAs` приложение уже не контролирует тот процесс так же, как обычный child +- значит `Cancel` в этом состоянии это скорее `stop waiting locally`, а не гарантированное terminate внешнего admin window + +Надёжный UX contract: +- если step ещё не ушёл во внешний elevated flow, `Cancel` действительно отменяет install locally +- если step уже ушёл во внешний elevated flow: + - app переводит себя в cancelled/abandoned waiting state + - явно пишет, что external administrator window may still be open + - после этого truth source всё равно fresh probe, а не предположение "мы точно всё остановили" + +## 10.7 Diagnostics + +Добавить diagnostic log рядом по стилю с `CliInstallerService`: +- detected platform +- chosen strategy +- PATH used +- package manager path +- raw exit code +- stderr tail +- verify result + +### 10.8 Raw log hygiene + +Нельзя бесконтрольно складывать весь terminal output в renderer/store. + +Нужно: +- ring buffer по строкам или байтам +- upper bound на память +- redaction rules для очевидно чувствительных фрагментов +- не persist raw install terminal logs на диск по умолчанию +- не логировать пользовательский input в PTY вообще +- пароль/keystrokes пользователя не должны попадать ни в raw chunks, ни в diagnostics + +Минимум: + +```ts +const MAX_RAW_CHUNKS = 400; +const MAX_RAW_BYTES = 256 * 1024; +``` + +## 11. Детальный implementation plan + +## Phase 1. Feature contracts and registration + +Файлы: +- `src/features/tmux-installer/contracts/api.ts` +- `src/features/tmux-installer/contracts/channels.ts` +- `src/features/tmux-installer/contracts/dto.ts` +- `src/features/tmux-installer/contracts/index.ts` +- `src/features/tmux-installer/main/index.ts` +- `src/features/tmux-installer/preload/createTmuxInstallerBridge.ts` +- `src/features/tmux-installer/preload/index.ts` +- `src/renderer/api/httpClient.ts` +- host registration points: + - `src/preload/index.ts` + - app shell main bootstrap + +Что делаем: +- расширяем типы +- добавляем install/invalidate/cancel/progress API +- browser-mode stubs возвращают safe no-op behavior + +## Phase 2. Main-process status refactor + +Файлы: +- `src/features/tmux-installer/main/index.ts` +- `src/features/tmux-installer/main/composition/createTmuxInstallerFeature.ts` +- `src/features/tmux-installer/main/adapters/input/ipc/registerTmuxInstallerIpc.ts` +- `src/features/tmux-installer/main/adapters/output/sources/TmuxStatusSourceAdapter.ts` +- `src/features/tmux-installer/core/application/use-cases/GetTmuxStatusUseCase.ts` +- host registration point: + - app shell main bootstrap, который импортирует только `@features/tmux-installer/main` + - если `src/main/ipc/tmux.ts` сохраняется ради совместимости, то только как thin registration shim + +Что делаем: +- выносим status computation из голого IPC handler в feature use case + adapter chain +- status probe должен использовать enriched PATH / interactive shell env, а не только process PATH +- для macOS дополнительно проверять common brew prefixes, иначе получим false negative после успешного install +- feature facade умеет: + - `getStatus()` + - `install()` + - `cancelInstall()` + - `invalidateStatus()` + - `setMainWindow()` + +## Phase 2.2 WSL preference persistence + +Новый модуль: +- `src/features/tmux-installer/main/infrastructure/wsl/TmuxWslPreferenceStore.ts` + +Что делаем: +- сохраняем `preferred distro` после явного Windows wizard choice или после первого успешного install/verify +- status service сначала пробует persisted distro +- если persisted distro больше не существует, store self-heals: + - mark stale + - clear preference + - вернуть UI в `re-select distro` / `manual guidance` + +## Phase 2.1 PTY plumbing refactor + +Файлы: +- `src/main/services/infrastructure/PtyTerminalService.ts` +- feature-local wrapper `src/features/tmux-installer/main/infrastructure/installer/TmuxInstallTerminalSession.ts` +- возможно новый attach-style renderer component вместо прямого reuse `TerminalModal` + +Что делаем: +- добавляем main-side control surface для PTY session +- installer service должен получать: + - raw chunks + - exit code + - ability to write input + - explicit dispose lifecycle +- renderer не должен быть владельцем installer process +- если attach UI делаем позже, installer всё равно остаётся source of truth + +## Phase 3. Strategy resolver + +Новый модуль: +- `src/features/tmux-installer/main/infrastructure/installer/TmuxInstallStrategyResolver.ts` + +Псевдокод: + +```ts +export async function resolveTmuxInstallPlan(): Promise { + if (process.platform === 'darwin') return resolveMacPlan(); + if (process.platform === 'linux') return resolveLinuxPlan(); + if (process.platform === 'win32') return resolveWindowsPlan(); + return manualOnlyPlan('Unsupported platform'); +} +``` + +Важно: +- resolver должен учитывать не только OS/package manager, но и локальные capability flags +- пример: если путь требует interactive `sudo`, а PTY capability недоступен, auto-install надо честно выключать и переводить пользователя в manual guidance + +## Phase 4. macOS install runner + +- detect `brew` +- else detect `port` +- else manual + +Нерискованный rule: +- не auto-install Homebrew +- не auto-run remote shell scripts + +## Phase 5. Linux install runner + +- parse `/etc/os-release` +- choose command +- run command in PTY +- stream raw logs +- verify +- if immutable host detected -> short-circuit to manual fallback +- if retry with `update` is needed, prefer doing it in the same PTY session + +## Phase 6. Renderer slice inside feature + +Новые файлы: +- `src/features/tmux-installer/renderer/adapters/TmuxInstallerBannerAdapter.ts` +- `src/features/tmux-installer/renderer/hooks/useTmuxInstallerBanner.ts` +- `src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx` +- `src/features/tmux-installer/renderer/utils/formatTmuxInstallerText.ts` +- `src/features/tmux-installer/renderer/index.ts` + +Не делать primary architecture через новый global slice, пока это не стало реально нужно нескольким независимым surface'ам. + +Лучше так: +- installer state canonical в main process +- renderer slice читает snapshot + подписывается на progress events +- local React state внутри feature hook достаточно для v1 +- если позже появится второй surface с тем же state, можно отдельно решить, нужен ли shared renderer cache + +Feature hook return shape: + +```ts +{ + viewModel: TmuxInstallerBannerViewModel; + actions: { + install: () => Promise; + cancel: () => Promise; + refresh: () => Promise; + toggleDetails: () => void; + }; +} +``` + +Hook boundary rules: +- hook не должен импортировать `api` напрямую +- hook не должен знать про IPC channel names +- hook не должен нормализовать platform-specific ошибки +- это ответственность renderer adapter и main-side use case/presenter layers + +## Phase 7. Banner UX + +Создать feature view: +- `src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx` + +И оставить `src/renderer/components/dashboard/TmuxStatusBanner.tsx` как thin wrapper: + +```tsx +import { TmuxInstallerBannerView } from '@features/tmux-installer/renderer'; + +export function TmuxStatusBanner(): JSX.Element { + return ; +} +``` + +Внутри feature view: + +- `Install tmux` button +- status line +- progress bar +- `Show details` / `Hide details` +- error block +- manual fallback buttons + +UI logic: +- если `autoInstall.supported === false`, не показывать install CTA +- если `needs_manual_step`, показывать step-specific CTA +- если `needs_restart`, показывать restart CTA +- если live installer session уже идёт, feature должен подхватить её через snapshot и не сбрасывать UI в `idle` + +## Phase 7.1 Feature integration points checklist + +Чтобы реализация реально соответствовала guide, в PR и в финальной implementation notes надо перечислить изменённые host integration points. + +Ожидаемые integration points для этой задачи: +- `src/features/tmux-installer/contracts/*` +- `src/features/tmux-installer/core/*` +- `src/features/tmux-installer/main/*` +- `src/features/tmux-installer/preload/*` +- `src/features/tmux-installer/renderer/*` +- app shell registration points for main/preload/renderer +- `src/renderer/components/dashboard/TmuxStatusBanner.tsx` + +Если появятся дополнительные host touchpoints, это должно быть явно объяснено, а не "само выросло". + +## Phase 7.2 Definition of done по feature standard + +Перед merge реализация считается готовой только если: +- feature живёт в `src/features/tmux-installer/` +- структура соответствует canonical template из `docs/FEATURE_ARCHITECTURE_STANDARD.md` +- `contracts`, `core`, `main`, `preload`, `renderer` заполнены осмысленно +- `core/domain` side-effect free +- `core/application` покрывает use case orchestration +- public imports идут только через feature entrypoints +- shared shell files не содержат business logic, только registration shims +- renderer business logic не осталась в `TmuxStatusBanner.tsx` +- есть как минимум: + - domain policy tests + - application use case tests + - critical renderer utility tests + - one adapter-level mapping test +- `pnpm typecheck` проходит +- `pnpm build` проходит +- import/lint guard rails не позволяют deep-import feature internals снаружи +- integration points перечислены в PR notes + +## Phase 8. Windows WSL wizard + +Сделать это отдельным sub-flow внутри feature use case + infrastructure path, а не if/else spaghetti inside banner. + +Новый helper: +- `src/features/tmux-installer/main/infrastructure/wsl/TmuxWslService.ts` + +Методы: + +```ts +interface TmuxWslService { + getStatus(): Promise; + installWsl(): Promise; + ensureDistro(): Promise; + ensureBootstrapReady(): Promise; + installTmuxInsideDistro(): Promise; + verifyTmux(): Promise; +} +``` + +Важно: +- `installWsl()` должен поддерживать сценарий `external elevated step` +- его контракт не должен предполагать "я всегда верну полный live log" +- success этого шага всегда подтверждается только последующим fresh probe + +## Phase 8.1 Windows elevated runner + +Новый модуль: +- `src/features/tmux-installer/main/infrastructure/wsl/WindowsElevatedStepRunner.ts` + +Ответственность: +- создавать temp working dir для elevated step +- писать helper script / args +- запускать PowerShell `Start-Process -Verb RunAs -Wait` +- читать result JSON / stderr tail file если helper успел их записать +- нормализовать outcome в: + - `elevated_succeeded` + - `elevated_cancelled` + - `elevated_failed` + - `elevated_unknown_outcome` + +Ключевое правило: +- даже `elevated_succeeded` не завершает installer flow само по себе +- после него всегда идёт fresh probe реального system state + +## Phase 9. Windows runtime follow-up + +Это можно вынести в отдельную PR/итерацию, но в этом документе оно считается обязательным follow-up. + +Файлы: +- `src/main/services/team/runtimeTeammateMode.ts` +- `src/main/ipc/tmux.ts` +- `src/main/services/team/TeamProvisioningService.ts` +- `src/features/tmux-installer/main/infrastructure/wsl/TmuxWslPathBridge.ts` + +Нужно: +- сделать WSL-aware tmux detection +- перестать hard-disable tmux path на Windows, если WSL tmux реально доступен +- добавить отдельный adapter для `tmux` команд через `wsl -e` +- добавить path bridge для `cwd` и project-related paths +- заложить `WSL_INTEROP` pinning strategy при создании isolated tmux server +- ввести explicit policy для supported `claude` binary shape внутри WSL runtime + +## 12. Пример скелета feature facade + +```ts +export class TmuxInstallerFeatureFacade { + #mainWindow: BrowserWindow | null = null; + #installing = false; + #activeChild: ChildProcess | null = null; + #activePty: TmuxInstallTerminalSession | null = null; + #cachedStatus: { value: TmuxStatus; at: number } | null = null; + + setMainWindow(window: BrowserWindow | null): void { + this.#mainWindow = window; + } + + async getStatus(): Promise { + // 1. detect host tmux + // 2. detect install capability + // 3. on Windows also detect WSL readiness + // 4. return unified snapshot + } + + async install(): Promise { + if (this.installing) { + this.sendProgress({ type: 'error', error: 'tmux installation is already in progress' }); + return; + } + + this.installing = true; + try { + this.sendProgress({ type: 'checking', detail: 'Detecting installation strategy...' }); + const plan = await resolveTmuxInstallPlan(); + + if (!plan.autoInstallSupported) { + this.sendProgress({ + type: 'error', + error: plan.reasonIfUnsupported ?? 'Automatic install is not available on this machine', + nextManualHint: plan.manualHints[0] ?? null, + }); + return; + } + + await this.executePlan(plan); + + this.sendProgress({ type: 'verifying', detail: 'Verifying tmux...' }); + const status = await this.invalidateAndGetFreshStatus(); + if (!status.effective.available) { + throw new Error('tmux installation finished but verification failed'); + } + + if (!status.effective.runtimeReady) { + this.sendProgress({ + type: 'needs_manual_step', + detail: 'tmux is installed, but runtime integration is not ready yet on this machine', + status, + }); + return; + } + + this.sendProgress({ + type: 'completed', + detail: `Installed ${status.effective.version ?? 'tmux'} via ${status.effective.location ?? 'unknown path'}`, + status, + }); + } catch (error) { + this.sendProgress({ + type: 'error', + error: normalizeTmuxInstallError(error).userMessage, + }); + } finally { + this.installing = false; + this.activeChild = null; + this.activePty = null; + } + } + + async cancelInstall(): Promise { + killProcessTree(this.activeChild); + this.activePty?.dispose(); + this.sendProgress({ type: 'cancelled', detail: 'Installation cancelled' }); + } + + private sendProgress(progress: TmuxInstallerProgress): void { + safeSendToRenderer(this.mainWindow, TMUX_INSTALLER_PROGRESS, progress); + } +} +``` + +## 12.2 Пример runtime readiness rule для Windows follow-up + +```ts +function isWindowsWslRuntimeReady(ctx: { + tmuxSmokeOk: boolean; + wslInteropPinned: boolean; + targetDistroExists: boolean; + translatedCwdExists: boolean; + claudeBinaryPath: string | null; + configRootSanityOk: boolean; +}): boolean { + if (!ctx.tmuxSmokeOk) return false; + if (!ctx.wslInteropPinned) return false; + if (!ctx.targetDistroExists) return false; + if (!ctx.translatedCwdExists) return false; + if (!ctx.claudeBinaryPath) return false; + if (!ctx.configRootSanityOk) return false; + + const lower = ctx.claudeBinaryPath.toLowerCase(); + return lower.endsWith('.exe'); +} +``` + +Это intentionally conservative rule. + +Смысл: +- лучше временно недовключить Windows WSL runtime, чем считать runtime-ready сценарий с `.cmd` wrapper или broken path translation + +## 12.1 Пример Windows elevated step runner + +```ts +interface WindowsElevatedStepResult { + outcome: 'elevated_succeeded' | 'elevated_cancelled' | 'elevated_failed' | 'elevated_unknown_outcome'; + detail?: string | null; + resultFilePath?: string | null; +} + +export class WindowsElevatedStepRunner { + async runWslInstall(args: string[]): Promise { + const tempDir = await fsp.mkdtemp(join(tmpdir(), 'tmux-wsl-install-')); + const resultFile = join(tempDir, 'result.json'); + const helperScript = join(tempDir, 'run-wsl-install.ps1'); + + await fsp.writeFile( + helperScript, + buildWslInstallScript({ + wslArgs: args, + resultFile, + }), + 'utf8' + ); + + const ps = await execFileNoThrow('powershell.exe', [ + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-Command', + `Start-Process -FilePath powershell.exe -Verb RunAs -Wait -ArgumentList '-NoProfile -ExecutionPolicy Bypass -File \"${escapePowerShell(helperScript)}\"'`, + ]); + + // Do not trust only PowerShell exit code. UAC cancellation and helper failures + // are better inferred from the result file plus a fresh WSL probe. + if (await fileExists(resultFile)) { + const payload = JSON.parse(await fsp.readFile(resultFile, 'utf8')) as { + ok?: boolean; + detail?: string; + }; + return { + outcome: payload.ok ? 'elevated_succeeded' : 'elevated_failed', + detail: payload.detail ?? null, + resultFilePath: resultFile, + }; + } + + if (looksLikeElevationCancelled(ps.stderr, ps.stdout, ps.code)) { + return { + outcome: 'elevated_cancelled', + detail: 'Administrator permission request was cancelled', + resultFilePath: null, + }; + } + + return { + outcome: 'elevated_unknown_outcome', + detail: ps.stderr || ps.stdout || null, + resultFilePath: null, + }; + } +} +``` + +Это не production-ready код, а reference shape. + +Главная идея здесь важнее синтаксиса: +- elevated Windows step отделён от PTY/Linux install logic +- результат helper script это только дополнительная диагностика +- truth source всё равно fresh `wsl --status` / `wsl --list` probe после шага + +## 13. Пример Linux resolver + +```ts +export async function resolveLinuxPackageManager(): Promise { + const osRelease = await readOsRelease(); + + if (isDistroFamily(osRelease, ['debian'])) return 'apt'; + if (isDistroFamily(osRelease, ['fedora', 'rhel'])) return (await hasBinary('dnf')) ? 'dnf' : 'yum'; + if (isDistroFamily(osRelease, ['suse', 'opensuse'])) return 'zypper'; + if (isDistroFamily(osRelease, ['arch'])) return 'pacman'; + + if (await hasBinary('apt-get')) return 'apt'; + if (await hasBinary('dnf')) return 'dnf'; + if (await hasBinary('yum')) return 'yum'; + if (await hasBinary('zypper')) return 'zypper'; + if (await hasBinary('pacman')) return 'pacman'; + + return 'unknown'; +} +``` + +## 14. Пример Windows WSL status probe + +```ts +async function getWslStatus(): Promise { + const wslStatus = await execWslNoThrow(['--status']); + if (wslStatus.code !== 0) { + return { + wslInstalled: false, + rebootRequired: false, + distroName: null, + distroVersion: null, + distroBootstrapped: false, + innerPackageManager: null, + tmuxAvailableInsideWsl: false, + tmuxVersion: null, + statusDetail: wslStatus.stderr || wslStatus.stdout || 'WSL not available', + }; + } + + const preferredDistro = await wslPreferenceStore.getPreferredDistro(); + const list = await execWslNoThrow(['--list', '--verbose']); + const distro = resolveTargetDistro({ + preferredDistro, + listOutput: list.stdout, + }); + const distroVersion = parseDefaultDistroVersion(list.stdout); + if (!distro) { + return { + wslInstalled: true, + rebootRequired: false, + distroName: null, + distroVersion: null, + distroBootstrapped: false, + innerPackageManager: null, + tmuxAvailableInsideWsl: false, + tmuxVersion: null, + statusDetail: 'WSL installed but no Linux distribution is configured', + }; + } + + const bootstrapProbe = await execWslNoThrow([ + '--distribution', + distro, + '--', + 'sh', + '-lc', + 'printf ready', + ]); + + if (bootstrapProbe.code !== 0 || !bootstrapProbe.stdout.includes('ready')) { + return { + wslInstalled: true, + rebootRequired: false, + distroName: distro, + distroVersion, + distroBootstrapped: false, + innerPackageManager: null, + tmuxAvailableInsideWsl: false, + tmuxVersion: null, + statusDetail: + bootstrapProbe.stderr || bootstrapProbe.stdout || 'Linux distro bootstrap is not finished', + }; + } + + const tmuxProbe = await execWslNoThrow([ + '--distribution', + distro, + '--', + 'tmux', + '-V', + ]); + return { + wslInstalled: true, + rebootRequired: false, + distroName: distro, + distroVersion, + distroBootstrapped: true, + innerPackageManager: null, + tmuxAvailableInsideWsl: tmuxProbe.code === 0, + tmuxVersion: tmuxProbe.code === 0 ? extractTmuxVersion(tmuxProbe.stdout || tmuxProbe.stderr) : null, + statusDetail: tmuxProbe.code === 0 ? null : tmuxProbe.stderr || tmuxProbe.stdout || null, + }; +} +``` + +Где `execWslNoThrow()`: +- сам выбирает подходящий `wsl.exe` candidate +- запускает с `encoding: buffer` +- затем декодирует stdout/stderr defensively, потому что `wsl.exe` output на Windows не всегда стабильно UTF-8 + +А `resolveTargetDistro()`: +- сначала пробует persisted preference +- если её нет или она stale, использует default distro / selected distro heuristic +- не должен silently switch runtime target, если preference сломалась и это меняет expected behavior пользователя + +А `parseDefaultDistroVersion()`: +- должен считаться best-effort helper только для diagnostics +- если parser не уверен, лучше вернуть `null`, чем принять неправильное runtime decision + +## 15. Error UX copy guidelines + +Плохой текст: +- `ENOENT` +- `spawn failed` +- `exit code 1` + +Хороший текст: +- `Homebrew was not found. Install Homebrew first or use the manual instructions below.` +- `The package manager asked for administrator privileges and the request was cancelled.` +- `Interactive installation is not available because terminal support is missing in this app build. Use the manual command below.` +- `WSL was installed, but Windows restart is still required before tmux can be set up.` +- `This Windows version does not support the automatic WSL setup flow used by the app. Follow the Microsoft manual steps below.` +- `WSL is available, but no Linux distribution is installed yet. Install Ubuntu first, then continue.` +- ` is installed in WSL, but its first-launch setup is not finished yet. Open it once, finish Linux user setup, then re-check.` +- `WSL setup needs an administrator step in a separate window. Complete that step, then come back and press Re-check.` + +## 16. Тест-план + +### Unit tests + +- status resolver for every platform +- package manager resolver +- error normalizer +- WSL output parsers +- WSL executable candidate resolver + UTF-16/mixed output decoding +- progress phase mapping +- WSL preferred-distro resolver +- elevated-step restart recovery logic +- locale-robust default-distro resolution +- supported Windows `claude` binary-shape policy +- locale-robust package-manager error classification +- config/auth-root sanity probe for Windows `claude.exe` from WSL +- tmux capability probes vs plain version string + +### Integration tests with mocks + +- install success on brew +- brew missing -> manual fallback +- apt sudo password prompt path +- package manager lock errors +- verification failure after install +- PTY capability missing -> no interactive auto-install path +- Windows: + - WSL missing + - WSL install goes through external elevated step and requires re-check + - external elevated step returns no result file -> unknown outcome -> fresh probe decides + - WSL install requires restart + - `wsl --status` unsupported on older build -> manual guidance + - distro missing + - distro bootstrap pending + - unsupported default distro -> manual or install-supported-distro guidance + - tmux install inside WSL success + - runtime smoke test passes only when same adapter path is used + - persisted preferred distro disappears -> self-heal without misleading "installed" state + - app restart during external elevated step -> self-heal from fresh probe, not stale in-memory state + - direct `wsl -e tmux ...` path works for tmux format strings with `#` + - host Windows resolver returns `.cmd` only -> runtime stays conservative, no false ready + - host `claude.exe` launches from WSL but config/auth root behaves unexpectedly -> runtime stays not ready + - `tmux -V` succeeds but required runtime capability probe fails -> no false ready + +### Manual matrix + +- macOS Apple Silicon + Homebrew +- macOS Intel + Homebrew +- macOS + MacPorts only +- Ubuntu +- Fedora +- Arch +- openSUSE +- Fedora Silverblue / similar immutable host -> manual fallback only +- Windows 11 clean machine without WSL +- Windows 11 with WSL but no distro bootstrap +- Windows 11 with ready supported distro +- Windows 11 with WSL 1 distro where tmux still works +- Windows 10 older build -> manual Microsoft guidance only +- Windows runtime with project on `C:\\...` path -> WSL path translation works +- Windows runtime with project on `\\\\wsl.localhost\\\\...` path -> distro match handling works +- Windows with multiple distros and changed default distro -> persisted target stays stable +- Windows runtime on `/mnt/c/...` project -> works but surfaces performance diagnostic + +## 16.1 Остаточные uncertainty points после самого жёсткого IOF + +Они уже не блокируют план, но их лучше закрыть маленькими spikes до полной реализации. + +1. Windows elevated helper UX + - 🎯 8 🛡️ 8 🧠 5 + - `100-200` строк spike + - Нужно проверить на реальной машине, как стабильно работает `Start-Process -Verb RunAs -Wait` + temp result file и какие коды/сообщения приходят при UAC cancel. + +2. WSL distro install path under corporate restrictions + - 🎯 7 🛡️ 7 🧠 4 + - `50-120` строк spike + - Нужно подтвердить, когда `wsl --install -d ...` реально помогает, а когда сразу надо переводить пользователя в manual Microsoft docs. + +3. PTY attach model для installer UI + - 🎯 8 🛡️ 9 🧠 6 + - `150-300` строк spike + - Нужно быстро выбрать: расширяем существующий `PtyTerminalService` или делаем небольшой отдельный installer-session adapter. + +4. WSL runtime path bridge + interop pinning + - 🎯 7 🛡️ 9 🧠 7 + - `150-350` строк spike + - Нужно проверить на реальном Windows path mix: `C:\\...`, `\\\\wsl.localhost\\...`, spaces, different distro names, и подтвердить поведение `WSL_INTEROP=/run/WSL/1_interop` для долгоживущего tmux server. + +5. Preferred distro persistence semantics + - 🎯 8 🛡️ 8 🧠 5 + - `80-180` строк spike + - Нужно подтвердить, где и как лучше хранить `preferred WSL distro`, чтобы не получить ложные переключения при смене default distro и при этом не создать лишнюю сложность миграции настроек. + +6. Windows `claude` binary model through WSL interop + - 🎯 7 🛡️ 8 🧠 6 + - `120-260` строк spike + - Нужно подтвердить на реальной машине: `claude.exe` path, `.cmd` fallback behavior, quoting, cleanup и минимальный smoke test именно через тот binary shape, который потом пойдёт в teammate runtime. + +7. Config/auth root sanity for `claude.exe` launched from WSL + - 🎯 6 🛡️ 8 🧠 6 + - `100-220` строк spike + - Нужно проверить, какой effective config/auth/session root реально использует host `claude.exe`, запущенный из WSL tmux pane, и не появляется ли drift между Windows root и WSL working directory. + +## 17. Rollout strategy + +### PR 1 + +- shared types +- main service skeleton +- macOS/Linux installer +- banner UI +- manual fallback UX + +### PR 2 + +- Windows WSL wizard +- richer status snapshot for WSL + +### PR 3 + +- Windows runtime enablement through WSL tmux + +Это лучше, чем пытаться протащить всё одним PR. + +## 18. Самый важный список "не наступить" + +- не делать Windows wizard без честного copy, если runtime ещё не использует WSL tmux +- не auto-install Homebrew в фоне +- не считать install успешным без `tmux -V` +- не считать `tmux:getStatus().effective.available === true` синонимом "host tmux есть" +- не смешивать service-owned installer PTY с renderer-owned terminal modal +- не использовать fake 0-100 network progress для package managers +- не терять stderr/stdout +- не запускать `sudo` через non-TTY child +- не делать default hidden root install внутри WSL +- не пытаться auto-manage immutable Linux hosts как обычный `dnf/apt` Linux +- не хардкодить PATH без shell env merge +- не запускать Windows WSL runtime без path translation layer +- не поднимать Windows tmux server в WSL без явного `WSL_INTEROP` pinning strategy +- не привязывать Windows runtime только к current default distro +- не молча менять целевой WSL distro после успешной установки tmux +- не считать `.cmd`/`.bat` эквивалентом `.exe` для Windows WSL runtime без отдельной валидации +- не считать успешный старт `claude.exe` из WSL достаточным доказательством корректного config/auth root +- не прятать manual fallback, если auto-install unavailable +- не делать installer logic inside React component + +## 19. Мой итоговый recommendation + +Делать именно так: + +1. Сначала качественный `tmux-installer` feature slice для `macOS/Linux` +2. Сразу же заложить правильный shared contract под Windows WSL +3. На Windows не останавливаться на "installer wizard only" +4. Обязательно follow-up на WSL-aware runtime support, иначе ценность Windows-части будет неполной + +Если делать по этому плану, получится действительно качественно: +- удобно +- понятно +- с нормальной диагностикой +- без дешёвых UX-обманок +- с честным fallback для ручной установки diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 67727de3..fc6d5aeb 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -73,6 +73,7 @@ export default defineConfig({ }, resolve: { alias: { + '@features': resolve(__dirname, 'src/features'), '@main': resolve(__dirname, 'src/main'), '@shared': resolve(__dirname, 'src/shared'), '@preload': resolve(__dirname, 'src/preload') @@ -111,6 +112,7 @@ export default defineConfig({ preload: { resolve: { alias: { + '@features': resolve(__dirname, 'src/features'), '@preload': resolve(__dirname, 'src/preload'), '@shared': resolve(__dirname, 'src/shared'), '@main': resolve(__dirname, 'src/main') @@ -141,6 +143,7 @@ export default defineConfig({ }, resolve: { alias: { + '@features': resolve(__dirname, 'src/features'), '@renderer': resolve(__dirname, 'src/renderer'), '@shared': resolve(__dirname, 'src/shared'), '@main': resolve(__dirname, 'src/main'), diff --git a/eslint.config.js b/eslint.config.js index a26f1660..4f4bcbb2 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -97,6 +97,54 @@ export default defineConfig([ }, }, + // Import plugin configuration - Feature main/preload slices + { + name: 'import-plugin-features-node', + files: ['src/features/**/main/**/*.ts', 'src/features/**/preload/**/*.ts'], + plugins: { + import: importPlugin, + }, + settings: { + 'import/resolver': { + typescript: { + alwaysTryTypes: true, + project: ['./tsconfig.node.json', './tsconfig.json'], + }, + }, + }, + rules: { + 'import/no-cycle': ['error', { maxDepth: 3, ignoreExternal: true }], + 'import/no-unresolved': 'error', + 'import/no-default-export': 'warn', + }, + }, + + // Import plugin configuration - Feature contracts/core/renderer slices + { + name: 'import-plugin-features-web', + files: [ + 'src/features/**/contracts/**/*.ts', + 'src/features/**/core/**/*.ts', + 'src/features/**/renderer/**/*.{ts,tsx}', + ], + plugins: { + import: importPlugin, + }, + settings: { + 'import/resolver': { + typescript: { + alwaysTryTypes: true, + project: ['./tsconfig.json', './tsconfig.node.json'], + }, + }, + }, + rules: { + 'import/no-cycle': ['error', { maxDepth: 3, ignoreExternal: true }], + 'import/no-unresolved': 'error', + 'import/no-default-export': 'warn', + }, + }, + // Module boundaries - Enforce Electron three-process architecture { name: 'module-boundaries', @@ -624,6 +672,137 @@ export default defineConfig([ }, }, + { + name: 'feature-public-entrypoints-only', + files: [ + 'src/main/**/*.{ts,tsx}', + 'src/preload/**/*.{ts,tsx}', + 'src/renderer/**/*.{ts,tsx}', + 'src/shared/**/*.{ts,tsx}', + ], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: [ + '@features/*/contracts/*', + '@features/*/core/**', + '@features/*/main/*', + '@features/*/preload/*', + '@features/*/renderer/*', + ], + message: 'Import feature public entrypoints only.', + }, + ], + }, + ], + }, + }, + + { + name: 'feature-core-domain-guards', + files: ['src/features/*/core/domain/**/*.ts'], + rules: { + 'no-restricted-imports': [ + 'error', + { + paths: [ + { name: 'electron', message: 'core/domain must stay Electron-free.' }, + { name: 'fastify', message: 'core/domain must stay transport-free.' }, + { name: 'child_process', message: 'core/domain must stay side-effect free.' }, + { name: 'node:child_process', message: 'core/domain must stay side-effect free.' }, + ], + patterns: [ + { + group: ['@main/*', '@preload/*', '@renderer/*'], + message: 'core/domain must stay process-agnostic.', + }, + { + group: ['@features/*/main/**', '@features/*/preload/**', '@features/*/renderer/**'], + message: 'core/domain must not import runtime or transport layers.', + }, + ], + }, + ], + }, + }, + + { + name: 'feature-core-application-guards', + files: ['src/features/*/core/application/**/*.ts'], + rules: { + 'no-restricted-imports': [ + 'error', + { + paths: [ + { name: 'electron', message: 'core/application must stay Electron-free.' }, + { name: 'fastify', message: 'core/application must stay transport-free.' }, + { name: 'child_process', message: 'core/application must not spawn processes directly.' }, + { + name: 'node:child_process', + message: 'core/application must not spawn processes directly.', + }, + ], + patterns: [ + { + group: ['@main/*', '@preload/*', '@renderer/*'], + message: 'core/application must stay framework-agnostic.', + }, + { + group: ['@features/*/main/**', '@features/*/preload/**', '@features/*/renderer/**'], + message: 'core/application must depend on ports, not runtime adapters.', + }, + ], + }, + ], + }, + }, + + { + name: 'feature-preload-guards', + files: ['src/features/*/preload/**/*.ts'], + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: [ + { + group: ['@main/*'], + message: 'Feature preload should not import app-shell main modules.', + }, + { + group: ['@features/*/main/**'], + message: 'Feature preload must not reach into feature main internals.', + }, + ], + }, + ], + }, + }, + + { + name: 'feature-renderer-ui-guards', + files: ['src/features/*/renderer/ui/**/*.{ts,tsx}'], + rules: { + 'no-restricted-imports': [ + 'error', + { + paths: [ + { name: '@renderer/api', message: 'renderer/ui must stay presentational.' }, + { name: '@renderer/store', message: 'renderer/ui must stay store-free.' }, + { name: 'electron', message: 'renderer/ui must stay Electron-free.' }, + ], + patterns: [ + { group: ['@main/*'], message: 'renderer/ui must not import main modules.' }, + { group: ['@renderer/store/*'], message: 'renderer/ui must stay store-free.' }, + ], + }, + ], + }, + }, + // === IMPORTANT: eslint-config-prettier MUST be LAST === // This disables all ESLint rules that conflict with Prettier // Prettier handles formatting, ESLint handles code quality diff --git a/package.json b/package.json index 9c9c5cdc..43d1506d 100644 --- a/package.json +++ b/package.json @@ -321,6 +321,9 @@ "tsconfig*.json" ], "paths": { + "@features/*": [ + "./src/features/*" + ], "@main/*": [ "./src/main/*" ], diff --git a/src/features/tmux-installer/contracts/api.ts b/src/features/tmux-installer/contracts/api.ts new file mode 100644 index 00000000..54be18b6 --- /dev/null +++ b/src/features/tmux-installer/contracts/api.ts @@ -0,0 +1,11 @@ +import type { TmuxInstallerProgress, TmuxInstallerSnapshot, TmuxStatus } from './dto'; + +export interface TmuxAPI { + getStatus: () => Promise; + getInstallerSnapshot: () => Promise; + install: () => Promise; + cancelInstall: () => Promise; + submitInstallerInput: (input: string) => Promise; + invalidateStatus: () => Promise; + onProgress: (callback: (event: unknown, data: TmuxInstallerProgress) => void) => () => void; +} diff --git a/src/features/tmux-installer/contracts/channels.ts b/src/features/tmux-installer/contracts/channels.ts new file mode 100644 index 00000000..0171c493 --- /dev/null +++ b/src/features/tmux-installer/contracts/channels.ts @@ -0,0 +1,7 @@ +export const TMUX_GET_STATUS = 'tmux:getStatus'; +export const TMUX_GET_INSTALLER_SNAPSHOT = 'tmux:getInstallerSnapshot'; +export const TMUX_INSTALL = 'tmux:install'; +export const TMUX_CANCEL_INSTALL = 'tmux:cancelInstall'; +export const TMUX_SUBMIT_INSTALLER_INPUT = 'tmux:submitInstallerInput'; +export const TMUX_INVALIDATE_STATUS = 'tmux:invalidateStatus'; +export const TMUX_INSTALLER_PROGRESS = 'tmux:progress'; diff --git a/src/features/tmux-installer/contracts/dto.ts b/src/features/tmux-installer/contracts/dto.ts new file mode 100644 index 00000000..88bbd776 --- /dev/null +++ b/src/features/tmux-installer/contracts/dto.ts @@ -0,0 +1,109 @@ +export type TmuxPlatform = 'darwin' | 'linux' | 'win32' | 'unknown'; + +export type TmuxInstallStrategy = + | 'homebrew' + | 'macports' + | 'apt' + | 'dnf' + | 'yum' + | 'zypper' + | 'pacman' + | 'wsl' + | 'manual' + | 'unknown'; + +export type TmuxInstallerPhase = + | 'idle' + | 'checking' + | 'preparing' + | 'requesting_privileges' + | 'pending_external_elevation' + | 'waiting_for_external_step' + | 'installing' + | 'verifying' + | 'needs_restart' + | 'needs_manual_step' + | 'completed' + | 'error' + | 'cancelled'; + +export interface TmuxInstallHint { + title: string; + description: string; + command?: string; + url?: string; +} + +export interface TmuxAutoInstallCapability { + supported: boolean; + strategy: TmuxInstallStrategy; + packageManagerLabel?: string | null; + requiresTerminalInput: boolean; + requiresAdmin: boolean; + requiresRestart: boolean; + mayOpenExternalWindow?: boolean; + reasonIfUnsupported?: string | null; + manualHints: TmuxInstallHint[]; +} + +export interface TmuxWslStatus { + wslInstalled: boolean; + rebootRequired: boolean; + distroName: string | null; + distroVersion: 1 | 2 | null; + distroBootstrapped: boolean; + innerPackageManager: TmuxInstallStrategy | null; + tmuxAvailableInsideWsl: boolean; + tmuxVersion: string | null; + tmuxBinaryPath: string | null; + statusDetail: string | null; +} + +export interface TmuxWslPreference { + preferredDistroName: string | null; + source: 'persisted' | 'default' | 'manual' | null; +} + +export interface TmuxBinaryProbe { + available: boolean; + version: string | null; + binaryPath: string | null; + error: string | null; +} + +export interface TmuxEffectiveAvailability { + available: boolean; + location: 'host' | 'wsl' | null; + version: string | null; + binaryPath: string | null; + runtimeReady: boolean; + detail: string | null; +} + +export interface TmuxStatus { + platform: TmuxPlatform; + nativeSupported: boolean; + checkedAt: string; + host: TmuxBinaryProbe; + effective: TmuxEffectiveAvailability; + error: string | null; + autoInstall: TmuxAutoInstallCapability; + wsl?: TmuxWslStatus | null; + wslPreference?: TmuxWslPreference | null; +} + +export interface TmuxInstallerSnapshot { + phase: TmuxInstallerPhase; + strategy: TmuxInstallStrategy | null; + message: string | null; + detail: string | null; + error: string | null; + canCancel: boolean; + acceptsInput: boolean; + inputPrompt: string | null; + inputSecret: boolean; + logs: string[]; + updatedAt: string; +} + +export type TmuxInstallerProgress = TmuxInstallerSnapshot; diff --git a/src/features/tmux-installer/contracts/index.ts b/src/features/tmux-installer/contracts/index.ts new file mode 100644 index 00000000..69f32f5a --- /dev/null +++ b/src/features/tmux-installer/contracts/index.ts @@ -0,0 +1,3 @@ +export type * from './api'; +export * from './channels'; +export type * from './dto'; diff --git a/src/features/tmux-installer/core/application/ports/TmuxInstallerRunnerPort.ts b/src/features/tmux-installer/core/application/ports/TmuxInstallerRunnerPort.ts new file mode 100644 index 00000000..3c79ee2b --- /dev/null +++ b/src/features/tmux-installer/core/application/ports/TmuxInstallerRunnerPort.ts @@ -0,0 +1,5 @@ +export interface TmuxInstallerRunnerPort { + install(): Promise; + cancel(): Promise; + submitInput(input: string): Promise; +} diff --git a/src/features/tmux-installer/core/application/ports/TmuxInstallerSnapshotPort.ts b/src/features/tmux-installer/core/application/ports/TmuxInstallerSnapshotPort.ts new file mode 100644 index 00000000..2198ebae --- /dev/null +++ b/src/features/tmux-installer/core/application/ports/TmuxInstallerSnapshotPort.ts @@ -0,0 +1,5 @@ +import type { TmuxInstallerSnapshot } from '@features/tmux-installer/contracts'; + +export interface TmuxInstallerSnapshotPort { + getSnapshot(): TmuxInstallerSnapshot; +} diff --git a/src/features/tmux-installer/core/application/ports/TmuxStatusSourcePort.ts b/src/features/tmux-installer/core/application/ports/TmuxStatusSourcePort.ts new file mode 100644 index 00000000..50e2d8a6 --- /dev/null +++ b/src/features/tmux-installer/core/application/ports/TmuxStatusSourcePort.ts @@ -0,0 +1,6 @@ +import type { TmuxStatus } from '@features/tmux-installer/contracts'; + +export interface TmuxStatusSourcePort { + getStatus(): Promise; + invalidateStatus(): void; +} diff --git a/src/features/tmux-installer/core/application/use-cases/CancelTmuxInstallUseCase.ts b/src/features/tmux-installer/core/application/use-cases/CancelTmuxInstallUseCase.ts new file mode 100644 index 00000000..7d16c630 --- /dev/null +++ b/src/features/tmux-installer/core/application/use-cases/CancelTmuxInstallUseCase.ts @@ -0,0 +1,13 @@ +import type { TmuxInstallerRunnerPort } from '../ports/TmuxInstallerRunnerPort'; + +export class CancelTmuxInstallUseCase { + readonly #runner: TmuxInstallerRunnerPort; + + constructor(runner: TmuxInstallerRunnerPort) { + this.#runner = runner; + } + + execute(): Promise { + return this.#runner.cancel(); + } +} diff --git a/src/features/tmux-installer/core/application/use-cases/GetTmuxInstallerSnapshotUseCase.ts b/src/features/tmux-installer/core/application/use-cases/GetTmuxInstallerSnapshotUseCase.ts new file mode 100644 index 00000000..2678a4b1 --- /dev/null +++ b/src/features/tmux-installer/core/application/use-cases/GetTmuxInstallerSnapshotUseCase.ts @@ -0,0 +1,14 @@ +import type { TmuxInstallerSnapshotPort } from '../ports/TmuxInstallerSnapshotPort'; +import type { TmuxInstallerSnapshot } from '@features/tmux-installer/contracts'; + +export class GetTmuxInstallerSnapshotUseCase { + readonly #snapshotPort: TmuxInstallerSnapshotPort; + + constructor(snapshotPort: TmuxInstallerSnapshotPort) { + this.#snapshotPort = snapshotPort; + } + + execute(): TmuxInstallerSnapshot { + return this.#snapshotPort.getSnapshot(); + } +} diff --git a/src/features/tmux-installer/core/application/use-cases/GetTmuxStatusUseCase.ts b/src/features/tmux-installer/core/application/use-cases/GetTmuxStatusUseCase.ts new file mode 100644 index 00000000..915c84e9 --- /dev/null +++ b/src/features/tmux-installer/core/application/use-cases/GetTmuxStatusUseCase.ts @@ -0,0 +1,14 @@ +import type { TmuxStatusSourcePort } from '../ports/TmuxStatusSourcePort'; +import type { TmuxStatus } from '@features/tmux-installer/contracts'; + +export class GetTmuxStatusUseCase { + readonly #statusSource: TmuxStatusSourcePort; + + constructor(statusSource: TmuxStatusSourcePort) { + this.#statusSource = statusSource; + } + + execute(): Promise { + return this.#statusSource.getStatus(); + } +} diff --git a/src/features/tmux-installer/core/application/use-cases/InstallTmuxUseCase.ts b/src/features/tmux-installer/core/application/use-cases/InstallTmuxUseCase.ts new file mode 100644 index 00000000..b2d1e7c2 --- /dev/null +++ b/src/features/tmux-installer/core/application/use-cases/InstallTmuxUseCase.ts @@ -0,0 +1,13 @@ +import type { TmuxInstallerRunnerPort } from '../ports/TmuxInstallerRunnerPort'; + +export class InstallTmuxUseCase { + readonly #runner: TmuxInstallerRunnerPort; + + constructor(runner: TmuxInstallerRunnerPort) { + this.#runner = runner; + } + + execute(): Promise { + return this.#runner.install(); + } +} diff --git a/src/features/tmux-installer/core/application/use-cases/SubmitTmuxInstallerInputUseCase.ts b/src/features/tmux-installer/core/application/use-cases/SubmitTmuxInstallerInputUseCase.ts new file mode 100644 index 00000000..059d9412 --- /dev/null +++ b/src/features/tmux-installer/core/application/use-cases/SubmitTmuxInstallerInputUseCase.ts @@ -0,0 +1,13 @@ +import type { TmuxInstallerRunnerPort } from '../ports/TmuxInstallerRunnerPort'; + +export class SubmitTmuxInstallerInputUseCase { + readonly #runner: TmuxInstallerRunnerPort; + + constructor(runner: TmuxInstallerRunnerPort) { + this.#runner = runner; + } + + execute(input: string): Promise { + return this.#runner.submitInput(input); + } +} diff --git a/src/features/tmux-installer/core/application/use-cases/__tests__/tmuxInstallerUseCases.test.ts b/src/features/tmux-installer/core/application/use-cases/__tests__/tmuxInstallerUseCases.test.ts new file mode 100644 index 00000000..106471ce --- /dev/null +++ b/src/features/tmux-installer/core/application/use-cases/__tests__/tmuxInstallerUseCases.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { CancelTmuxInstallUseCase } from '../CancelTmuxInstallUseCase'; +import { GetTmuxInstallerSnapshotUseCase } from '../GetTmuxInstallerSnapshotUseCase'; +import { GetTmuxStatusUseCase } from '../GetTmuxStatusUseCase'; +import { InstallTmuxUseCase } from '../InstallTmuxUseCase'; + +import type { TmuxInstallerRunnerPort } from '../../ports/TmuxInstallerRunnerPort'; +import type { TmuxInstallerSnapshotPort } from '../../ports/TmuxInstallerSnapshotPort'; +import type { TmuxStatusSourcePort } from '../../ports/TmuxStatusSourcePort'; +import type { TmuxInstallerSnapshot, TmuxStatus } from '@features/tmux-installer/contracts'; + +describe('tmux installer use cases', () => { + it('delegates status loading to the status source port', async () => { + const status: TmuxStatus = { + platform: 'linux', + nativeSupported: true, + checkedAt: new Date().toISOString(), + host: { + available: false, + version: null, + binaryPath: null, + error: null, + }, + effective: { + available: false, + location: null, + version: null, + binaryPath: null, + runtimeReady: false, + detail: 'detail', + }, + error: null, + autoInstall: { + supported: true, + strategy: 'apt', + packageManagerLabel: 'APT', + requiresTerminalInput: false, + requiresAdmin: true, + requiresRestart: false, + mayOpenExternalWindow: false, + reasonIfUnsupported: null, + manualHints: [], + }, + }; + const getStatusMock = vi.fn().mockResolvedValue(status); + const statusSource: TmuxStatusSourcePort = { + getStatus: getStatusMock, + invalidateStatus: vi.fn(), + }; + + const result = await new GetTmuxStatusUseCase(statusSource).execute(); + + expect(result).toBe(status); + expect(getStatusMock).toHaveBeenCalledTimes(1); + }); + + it('delegates install and cancel orchestration to the runner port', async () => { + const installMock = vi.fn().mockResolvedValue(undefined); + const cancelMock = vi.fn().mockResolvedValue(undefined); + const runner: TmuxInstallerRunnerPort = { + install: installMock, + cancel: cancelMock, + submitInput: vi.fn().mockResolvedValue(undefined), + }; + + await new InstallTmuxUseCase(runner).execute(); + await new CancelTmuxInstallUseCase(runner).execute(); + + expect(installMock).toHaveBeenCalledTimes(1); + expect(cancelMock).toHaveBeenCalledTimes(1); + }); + + it('returns the snapshot from the snapshot port unchanged', () => { + const snapshot: TmuxInstallerSnapshot = { + phase: 'installing', + strategy: 'homebrew', + message: 'Installing...', + detail: null, + error: null, + canCancel: true, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + logs: ['line 1'], + updatedAt: new Date().toISOString(), + }; + const getSnapshotMock = vi.fn().mockReturnValue(snapshot); + const snapshotPort: TmuxInstallerSnapshotPort = { + getSnapshot: getSnapshotMock, + }; + + const result = new GetTmuxInstallerSnapshotUseCase(snapshotPort).execute(); + + expect(result).toBe(snapshot); + expect(getSnapshotMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/features/tmux-installer/core/domain/policies/__tests__/buildTmuxAutoInstallCapability.test.ts b/src/features/tmux-installer/core/domain/policies/__tests__/buildTmuxAutoInstallCapability.test.ts new file mode 100644 index 00000000..5a62b58a --- /dev/null +++ b/src/features/tmux-installer/core/domain/policies/__tests__/buildTmuxAutoInstallCapability.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest'; + +import { buildTmuxAutoInstallCapability } from '../buildTmuxAutoInstallCapability'; + +describe('buildTmuxAutoInstallCapability', () => { + it('supports Homebrew installs on macOS without extra terminal input', () => { + const capability = buildTmuxAutoInstallCapability({ + platform: 'darwin', + strategy: 'homebrew', + packageManagerLabel: 'Homebrew', + nonInteractivePrivilegeAvailable: true, + }); + + expect(capability).toMatchObject({ + supported: true, + strategy: 'homebrew', + requiresTerminalInput: false, + requiresAdmin: false, + requiresRestart: false, + }); + expect(capability.manualHints.some((hint) => hint.command === 'brew install tmux')).toBe(true); + }); + + it('falls back to manual terminal install when sudo cannot run non-interactively', () => { + const capability = buildTmuxAutoInstallCapability({ + platform: 'linux', + strategy: 'apt', + packageManagerLabel: 'APT', + nonInteractivePrivilegeAvailable: false, + }); + + expect(capability).toMatchObject({ + supported: false, + strategy: 'apt', + requiresTerminalInput: true, + requiresAdmin: true, + requiresRestart: false, + }); + expect(capability.reasonIfUnsupported).toContain('Administrator privileges are required'); + }); + + it('keeps auto-install enabled when interactive terminal input is available', () => { + const capability = buildTmuxAutoInstallCapability({ + platform: 'linux', + strategy: 'apt', + packageManagerLabel: 'APT', + nonInteractivePrivilegeAvailable: false, + interactiveTerminalAvailable: true, + }); + + expect(capability).toMatchObject({ + supported: true, + strategy: 'apt', + requiresTerminalInput: true, + requiresAdmin: true, + }); + }); + + it('keeps immutable Linux hosts manual-only in this iteration', () => { + const capability = buildTmuxAutoInstallCapability({ + platform: 'linux', + strategy: 'apt', + packageManagerLabel: 'APT', + immutableHost: true, + nonInteractivePrivilegeAvailable: true, + }); + + expect(capability).toMatchObject({ + supported: false, + strategy: 'manual', + requiresAdmin: true, + requiresRestart: false, + }); + expect(capability.reasonIfUnsupported).toContain('Immutable Linux hosts'); + }); + + it('marks Windows as a WSL follow-up flow for now', () => { + const capability = buildTmuxAutoInstallCapability({ + platform: 'win32', + strategy: 'wsl', + packageManagerLabel: 'WSL', + nonInteractivePrivilegeAvailable: false, + }); + + expect(capability).toMatchObject({ + supported: false, + strategy: 'wsl', + requiresTerminalInput: true, + requiresAdmin: true, + requiresRestart: true, + mayOpenExternalWindow: true, + }); + expect(capability.reasonIfUnsupported).toContain('not wired'); + }); +}); diff --git a/src/features/tmux-installer/core/domain/policies/__tests__/buildTmuxEffectiveAvailability.test.ts b/src/features/tmux-installer/core/domain/policies/__tests__/buildTmuxEffectiveAvailability.test.ts new file mode 100644 index 00000000..2f9f3f23 --- /dev/null +++ b/src/features/tmux-installer/core/domain/policies/__tests__/buildTmuxEffectiveAvailability.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; + +import { buildTmuxEffectiveAvailability } from '../buildTmuxEffectiveAvailability'; + +describe('buildTmuxEffectiveAvailability', () => { + it('marks host tmux as runtime-ready on native platforms', () => { + const result = buildTmuxEffectiveAvailability({ + platform: 'linux', + nativeSupported: true, + host: { + available: true, + version: 'tmux 3.4', + binaryPath: '/usr/bin/tmux', + error: null, + }, + wsl: null, + }); + + expect(result.available).toBe(true); + expect(result.location).toBe('host'); + expect(result.runtimeReady).toBe(true); + }); + + it('prefers WSL tmux on Windows when it is available', () => { + const result = buildTmuxEffectiveAvailability({ + platform: 'win32', + nativeSupported: false, + host: { + available: false, + version: null, + binaryPath: null, + error: null, + }, + wsl: { + wslInstalled: true, + rebootRequired: false, + distroName: 'Ubuntu', + distroVersion: 2, + distroBootstrapped: true, + innerPackageManager: 'apt', + tmuxAvailableInsideWsl: true, + tmuxVersion: 'tmux 3.4', + tmuxBinaryPath: '/usr/bin/tmux', + statusDetail: 'tmux is available in WSL.', + }, + }); + + expect(result.available).toBe(true); + expect(result.location).toBe('wsl'); + expect(result.runtimeReady).toBe(true); + expect(result.version).toBe('tmux 3.4'); + }); + + it('keeps Windows host tmux non-runtime-ready without WSL tmux', () => { + const result = buildTmuxEffectiveAvailability({ + platform: 'win32', + nativeSupported: false, + host: { + available: true, + version: 'tmux 3.4', + binaryPath: 'C:\\tmux.exe', + error: null, + }, + wsl: { + wslInstalled: true, + rebootRequired: false, + distroName: 'Ubuntu', + distroVersion: 2, + distroBootstrapped: true, + innerPackageManager: 'apt', + tmuxAvailableInsideWsl: false, + tmuxVersion: null, + tmuxBinaryPath: null, + statusDetail: 'tmux is missing in WSL.', + }, + }); + + expect(result.available).toBe(true); + expect(result.location).toBe('host'); + expect(result.runtimeReady).toBe(false); + }); +}); diff --git a/src/features/tmux-installer/core/domain/policies/buildTmuxAutoInstallCapability.ts b/src/features/tmux-installer/core/domain/policies/buildTmuxAutoInstallCapability.ts new file mode 100644 index 00000000..1f232e6a --- /dev/null +++ b/src/features/tmux-installer/core/domain/policies/buildTmuxAutoInstallCapability.ts @@ -0,0 +1,192 @@ +import type { + TmuxAutoInstallCapability, + TmuxInstallHint, + TmuxInstallStrategy, + TmuxPlatform, +} from '@features/tmux-installer/contracts'; + +interface BuildTmuxAutoInstallCapabilityInput { + platform: TmuxPlatform; + strategy: TmuxInstallStrategy; + packageManagerLabel?: string | null; + immutableHost?: boolean; + nonInteractivePrivilegeAvailable?: boolean; + interactiveTerminalAvailable?: boolean; +} + +const OFFICIAL_TMUX_INSTALL_URL = 'https://github.com/tmux/tmux/wiki/Installing'; +const TMUX_README_URL = 'https://github.com/tmux/tmux/blob/master/README'; +const HOMEBREW_TMUX_URL = 'https://formulae.brew.sh/formula/tmux'; +const MACPORTS_TMUX_URL = 'https://ports.macports.org/port/tmux/'; +const MICROSOFT_WSL_INSTALL_URL = 'https://learn.microsoft.com/en-us/windows/wsl/install'; + +function buildManualHints(platform: TmuxPlatform): TmuxInstallHint[] { + if (platform === 'darwin') { + return [ + { + title: 'Homebrew', + description: 'Recommended install path on macOS.', + command: 'brew install tmux', + }, + { + title: 'MacPorts', + description: 'Alternative macOS package manager.', + command: 'sudo port install tmux', + }, + { + title: 'tmux guide', + description: 'Official installation guide.', + url: OFFICIAL_TMUX_INSTALL_URL, + }, + { title: 'Homebrew', description: 'tmux package page.', url: HOMEBREW_TMUX_URL }, + { title: 'MacPorts', description: 'tmux port page.', url: MACPORTS_TMUX_URL }, + ]; + } + + if (platform === 'linux') { + return [ + { title: 'APT', description: 'Debian/Ubuntu', command: 'sudo apt install tmux' }, + { title: 'DNF', description: 'Fedora/RHEL', command: 'sudo dnf install tmux' }, + { title: 'YUM', description: 'Older RHEL/CentOS', command: 'sudo yum install tmux' }, + { title: 'Zypper', description: 'openSUSE/SLES', command: 'sudo zypper install tmux' }, + { title: 'Pacman', description: 'Arch Linux', command: 'sudo pacman -S tmux' }, + { + title: 'tmux guide', + description: 'Official installation guide.', + url: OFFICIAL_TMUX_INSTALL_URL, + }, + ]; + } + + if (platform === 'win32') { + return [ + { + title: 'Install WSL', + description: 'Install Windows Subsystem for Linux.', + command: 'wsl --install --no-distribution', + }, + { + title: 'Install Ubuntu', + description: 'Recommended WSL distro for the tmux runtime path.', + command: 'wsl --install -d Ubuntu --no-launch', + }, + { + title: 'Install tmux in WSL', + description: 'Run this inside Ubuntu or another Linux distro.', + command: 'sudo apt install tmux', + }, + { title: 'tmux README', description: 'tmux upstream platform notes.', url: TMUX_README_URL }, + { + title: 'tmux guide', + description: 'Official installation guide.', + url: OFFICIAL_TMUX_INSTALL_URL, + }, + { + title: 'Microsoft WSL', + description: 'Official WSL installation docs.', + url: MICROSOFT_WSL_INSTALL_URL, + }, + ]; + } + + return [ + { + title: 'tmux guide', + description: 'Official installation guide.', + url: OFFICIAL_TMUX_INSTALL_URL, + }, + ]; +} + +export function buildTmuxAutoInstallCapability( + input: BuildTmuxAutoInstallCapabilityInput +): TmuxAutoInstallCapability { + const manualHints = buildManualHints(input.platform); + const requiresAdmin = + input.strategy === 'macports' || + input.strategy === 'apt' || + input.strategy === 'dnf' || + input.strategy === 'yum' || + input.strategy === 'zypper' || + input.strategy === 'pacman' || + input.strategy === 'wsl'; + + if (input.platform === 'win32') { + return { + supported: false, + strategy: 'wsl', + packageManagerLabel: 'WSL', + requiresTerminalInput: true, + requiresAdmin: true, + requiresRestart: true, + mayOpenExternalWindow: true, + reasonIfUnsupported: 'Windows WSL wizard is planned but not wired in this iteration yet.', + manualHints, + }; + } + + if (input.platform === 'linux' && input.immutableHost) { + return { + supported: false, + strategy: 'manual', + packageManagerLabel: input.packageManagerLabel ?? null, + requiresTerminalInput: false, + requiresAdmin: true, + requiresRestart: false, + reasonIfUnsupported: 'Immutable Linux hosts are manual-only in this iteration.', + manualHints, + }; + } + + if (input.strategy === 'manual' || input.strategy === 'unknown') { + return { + supported: false, + strategy: input.strategy, + packageManagerLabel: input.packageManagerLabel ?? null, + requiresTerminalInput: false, + requiresAdmin, + requiresRestart: false, + reasonIfUnsupported: 'No supported package manager was detected for automatic installation.', + manualHints, + }; + } + + if (requiresAdmin && !input.nonInteractivePrivilegeAvailable) { + if (input.interactiveTerminalAvailable) { + return { + supported: true, + strategy: input.strategy, + packageManagerLabel: input.packageManagerLabel ?? null, + requiresTerminalInput: true, + requiresAdmin: true, + requiresRestart: false, + reasonIfUnsupported: null, + manualHints, + }; + } + + return { + supported: false, + strategy: input.strategy, + packageManagerLabel: input.packageManagerLabel ?? null, + requiresTerminalInput: true, + requiresAdmin: true, + requiresRestart: false, + reasonIfUnsupported: + 'Administrator privileges are required. Run the manual install command in a terminal.', + manualHints, + }; + } + + return { + supported: true, + strategy: input.strategy, + packageManagerLabel: input.packageManagerLabel ?? null, + requiresTerminalInput: false, + requiresAdmin, + requiresRestart: false, + mayOpenExternalWindow: false, + reasonIfUnsupported: null, + manualHints, + }; +} diff --git a/src/features/tmux-installer/core/domain/policies/buildTmuxEffectiveAvailability.ts b/src/features/tmux-installer/core/domain/policies/buildTmuxEffectiveAvailability.ts new file mode 100644 index 00000000..cb4a0bf0 --- /dev/null +++ b/src/features/tmux-installer/core/domain/policies/buildTmuxEffectiveAvailability.ts @@ -0,0 +1,93 @@ +import type { + TmuxBinaryProbe, + TmuxEffectiveAvailability, + TmuxPlatform, + TmuxWslStatus, +} from '@features/tmux-installer/contracts'; + +interface BuildTmuxEffectiveAvailabilityInput { + platform: TmuxPlatform; + nativeSupported: boolean; + host: TmuxBinaryProbe; + wsl: TmuxWslStatus | null; +} + +export function buildTmuxEffectiveAvailability( + input: BuildTmuxEffectiveAvailabilityInput +): TmuxEffectiveAvailability { + if (input.platform === 'win32') { + if (input.wsl?.tmuxAvailableInsideWsl) { + return { + available: true, + location: 'wsl', + version: input.wsl.tmuxVersion, + binaryPath: input.wsl.tmuxBinaryPath, + runtimeReady: input.wsl.distroBootstrapped, + detail: input.wsl.distroBootstrapped + ? 'tmux is available inside WSL for the persistent teammate runtime.' + : 'tmux is installed inside WSL, but the Linux distro still needs first-launch setup.', + }; + } + + if (input.host.available) { + return { + available: true, + location: 'host', + version: input.host.version, + binaryPath: input.host.binaryPath, + runtimeReady: false, + detail: + 'tmux was found on Windows, but the app currently relies on a WSL-backed tmux runtime for the most reliable teammate path.', + }; + } + + if (!input.wsl?.wslInstalled) { + return { + available: false, + location: null, + version: null, + binaryPath: null, + runtimeReady: false, + detail: + input.wsl?.statusDetail ?? + 'You can keep using the app, but Windows needs WSL before tmux can improve teammate reliability.', + }; + } + + return { + available: false, + location: null, + version: null, + binaryPath: null, + runtimeReady: false, + detail: + input.wsl?.statusDetail ?? + 'WSL is available, but tmux is not ready there yet. Finish the Linux setup, install tmux, then re-check.', + }; + } + + if (input.host.available) { + return { + available: true, + location: 'host', + version: input.host.version, + binaryPath: input.host.binaryPath, + runtimeReady: input.nativeSupported, + detail: 'tmux is available for the persistent teammate runtime.', + }; + } + + return { + available: false, + location: null, + version: null, + binaryPath: null, + runtimeReady: false, + detail: + input.platform === 'darwin' + ? 'You can keep using the app, but tmux improves persistent teammate reliability and restart behavior.' + : input.platform === 'linux' + ? 'You can keep using the app, but tmux improves long-running teammate stability and cleaner recovery.' + : 'You can keep using the app, but tmux improves persistent teammate reliability.', + }; +} diff --git a/src/features/tmux-installer/main/adapters/input/ipc/registerTmuxInstallerIpc.ts b/src/features/tmux-installer/main/adapters/input/ipc/registerTmuxInstallerIpc.ts new file mode 100644 index 00000000..378daf17 --- /dev/null +++ b/src/features/tmux-installer/main/adapters/input/ipc/registerTmuxInstallerIpc.ts @@ -0,0 +1,84 @@ +import { + TMUX_CANCEL_INSTALL, + TMUX_GET_INSTALLER_SNAPSHOT, + TMUX_GET_STATUS, + TMUX_INSTALL, + TMUX_INVALIDATE_STATUS, + TMUX_SUBMIT_INSTALLER_INPUT, +} from '@features/tmux-installer/contracts'; +import { getErrorMessage } from '@shared/utils/errorHandling'; +import { createLogger } from '@shared/utils/logger'; + +import type { TmuxInstallerFeatureFacade } from '../../../composition/createTmuxInstallerFeature'; +import type { TmuxInstallerSnapshot, TmuxStatus } from '@features/tmux-installer/contracts'; +import type { IpcResult } from '@shared/types'; +import type { IpcMain, IpcMainInvokeEvent } from 'electron'; + +const logger = createLogger('Feature:tmux-installer:ipc'); + +export function registerTmuxInstallerIpc( + ipcMain: IpcMain, + feature: TmuxInstallerFeatureFacade +): void { + ipcMain.handle( + TMUX_GET_STATUS, + (_event: IpcMainInvokeEvent): Promise> => + withIpcResult(() => feature.getStatus()) + ); + ipcMain.handle( + TMUX_GET_INSTALLER_SNAPSHOT, + (_event: IpcMainInvokeEvent): IpcResult => + withSyncIpcResult(() => feature.getInstallerSnapshot()) + ); + ipcMain.handle( + TMUX_INSTALL, + (_event: IpcMainInvokeEvent): Promise> => withIpcResult(() => feature.install()) + ); + ipcMain.handle( + TMUX_CANCEL_INSTALL, + (_event: IpcMainInvokeEvent): Promise> => + withIpcResult(() => feature.cancelInstall()) + ); + ipcMain.handle( + TMUX_SUBMIT_INSTALLER_INPUT, + (_event: IpcMainInvokeEvent, input: string): Promise> => + withIpcResult(() => feature.submitInstallerInput(input)) + ); + ipcMain.handle( + TMUX_INVALIDATE_STATUS, + (_event: IpcMainInvokeEvent): IpcResult => + withSyncIpcResult(() => { + feature.invalidateStatus(); + return undefined; + }) + ); + logger.info('tmux installer IPC handlers registered'); +} + +export function removeTmuxInstallerIpc(ipcMain: IpcMain): void { + ipcMain.removeHandler(TMUX_GET_STATUS); + ipcMain.removeHandler(TMUX_GET_INSTALLER_SNAPSHOT); + ipcMain.removeHandler(TMUX_INSTALL); + ipcMain.removeHandler(TMUX_CANCEL_INSTALL); + ipcMain.removeHandler(TMUX_SUBMIT_INSTALLER_INPUT); + ipcMain.removeHandler(TMUX_INVALIDATE_STATUS); + logger.info('tmux installer IPC handlers removed'); +} + +async function withIpcResult(work: () => Promise): Promise> { + try { + return { success: true, data: await work() }; + } catch (error) { + const message = getErrorMessage(error); + return { success: false, error: message }; + } +} + +function withSyncIpcResult(work: () => T): IpcResult { + try { + return { success: true, data: work() }; + } catch (error) { + const message = getErrorMessage(error); + return { success: false, error: message }; + } +} diff --git a/src/features/tmux-installer/main/adapters/output/presenters/TmuxInstallerProgressPresenter.ts b/src/features/tmux-installer/main/adapters/output/presenters/TmuxInstallerProgressPresenter.ts new file mode 100644 index 00000000..64a1c999 --- /dev/null +++ b/src/features/tmux-installer/main/adapters/output/presenters/TmuxInstallerProgressPresenter.ts @@ -0,0 +1,17 @@ +import { TMUX_INSTALLER_PROGRESS } from '@features/tmux-installer/contracts'; +import { safeSendToRenderer } from '@main/utils/safeWebContentsSend'; + +import type { TmuxInstallerSnapshot } from '@features/tmux-installer/contracts'; +import type { BrowserWindow } from 'electron'; + +export class TmuxInstallerProgressPresenter { + #mainWindow: BrowserWindow | null = null; + + setMainWindow(window: BrowserWindow | null): void { + this.#mainWindow = window; + } + + present(snapshot: TmuxInstallerSnapshot): void { + safeSendToRenderer(this.#mainWindow, TMUX_INSTALLER_PROGRESS, snapshot); + } +} diff --git a/src/features/tmux-installer/main/adapters/output/runtime/TmuxInstallerRunnerAdapter.ts b/src/features/tmux-installer/main/adapters/output/runtime/TmuxInstallerRunnerAdapter.ts new file mode 100644 index 00000000..5553a07d --- /dev/null +++ b/src/features/tmux-installer/main/adapters/output/runtime/TmuxInstallerRunnerAdapter.ts @@ -0,0 +1,607 @@ +import { TmuxCommandRunner } from '@features/tmux-installer/main/infrastructure/installer/TmuxCommandRunner'; +import { TmuxInstallStrategyResolver } from '@features/tmux-installer/main/infrastructure/installer/TmuxInstallStrategyResolver'; +import { TmuxInstallTerminalSession } from '@features/tmux-installer/main/infrastructure/installer/TmuxInstallTerminalSession'; +import { TmuxWslService } from '@features/tmux-installer/main/infrastructure/wsl/TmuxWslService'; +import { WindowsElevatedStepRunner } from '@features/tmux-installer/main/infrastructure/wsl/WindowsElevatedStepRunner'; +import { getErrorMessage } from '@shared/utils/errorHandling'; + +import type { TmuxInstallerProgressPresenter } from '../presenters/TmuxInstallerProgressPresenter'; +import type { TmuxInstallerSnapshot, TmuxStatus } from '@features/tmux-installer/contracts'; +import type { TmuxInstallerRunnerPort } from '@features/tmux-installer/core/application/ports/TmuxInstallerRunnerPort'; +import type { TmuxInstallerSnapshotPort } from '@features/tmux-installer/core/application/ports/TmuxInstallerSnapshotPort'; +import type { TmuxStatusSourcePort } from '@features/tmux-installer/core/application/ports/TmuxStatusSourcePort'; +import type { TmuxInstallPlan } from '@features/tmux-installer/main/infrastructure/installer/TmuxInstallStrategyResolver'; + +const MAX_LOG_LINES = 400; +const RETRY_WITH_UPDATE_PATTERNS = ['unable to locate package', 'failed to fetch']; +const RECOMMENDED_WSL_DISTRO_NAME = 'Ubuntu'; + +class TmuxInstallCancelledError extends Error { + constructor() { + super('tmux installation cancelled'); + this.name = 'TmuxInstallCancelledError'; + } +} + +export class TmuxInstallerRunnerAdapter + implements TmuxInstallerRunnerPort, TmuxInstallerSnapshotPort +{ + readonly #statusSource: TmuxStatusSourcePort; + readonly #strategyResolver: TmuxInstallStrategyResolver; + readonly #commandRunner: TmuxCommandRunner; + readonly #terminalSession: TmuxInstallTerminalSession; + readonly #wslService: TmuxWslService; + readonly #windowsElevatedStepRunner: WindowsElevatedStepRunner; + readonly #presenter: TmuxInstallerProgressPresenter; + #cancelRequested = false; + #snapshot: TmuxInstallerSnapshot = { + phase: 'idle', + strategy: null, + message: null, + detail: null, + error: null, + canCancel: false, + logs: [], + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + updatedAt: new Date().toISOString(), + }; + + constructor( + statusSource: TmuxStatusSourcePort, + presenter: TmuxInstallerProgressPresenter, + strategyResolver = new TmuxInstallStrategyResolver(), + commandRunner = new TmuxCommandRunner(), + terminalSession = new TmuxInstallTerminalSession(), + wslService = new TmuxWslService(), + windowsElevatedStepRunner = new WindowsElevatedStepRunner() + ) { + this.#statusSource = statusSource; + this.#presenter = presenter; + this.#strategyResolver = strategyResolver; + this.#commandRunner = commandRunner; + this.#terminalSession = terminalSession; + this.#wslService = wslService; + this.#windowsElevatedStepRunner = windowsElevatedStepRunner; + } + + getSnapshot(): TmuxInstallerSnapshot { + return { ...this.#snapshot, logs: [...this.#snapshot.logs] }; + } + + async install(): Promise { + if (this.#snapshot.canCancel) { + throw new Error('tmux installation is already in progress'); + } + this.#cancelRequested = false; + + const currentStatus = await this.#statusSource.getStatus(); + if (currentStatus.effective.runtimeReady) { + this.#setSnapshot({ + phase: 'completed', + strategy: currentStatus.autoInstall.strategy, + message: 'tmux is already installed', + detail: currentStatus.effective.detail, + error: null, + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + resetLogs: true, + }); + return; + } + + if (currentStatus.platform === 'win32') { + await this.#installOnWindows(currentStatus); + return; + } + + const plan = await this.#strategyResolver.resolve(); + if (!plan.capability.supported || !plan.command) { + this.#setSnapshot({ + phase: 'needs_manual_step', + strategy: plan.capability.strategy, + message: 'Automatic install is not available in this environment', + detail: plan.capability.reasonIfUnsupported ?? null, + error: null, + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + resetLogs: true, + }); + return; + } + + try { + await this.#runResolvedPlan(plan); + } catch (error) { + if (this.#isCancelledError(error) || this.#cancelRequested) { + return; + } + this.#setSnapshot({ + phase: 'error', + strategy: plan.capability.strategy, + message: 'tmux installation failed', + detail: null, + error: getErrorMessage(error), + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + }); + throw error; + } + } + + async cancel(): Promise { + if (!this.#snapshot.canCancel) { + return; + } + + this.#cancelRequested = true; + this.#commandRunner.cancel(); + this.#terminalSession.cancel(); + this.#setSnapshot({ + phase: 'cancelled', + strategy: this.#snapshot.strategy, + message: 'tmux installation cancelled', + detail: null, + error: null, + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + }); + } + + async submitInput(input: string): Promise { + if (!this.#snapshot.acceptsInput) { + throw new Error('tmux installer is not waiting for terminal input right now'); + } + + this.#terminalSession.writeLine(input); + } + + async #installOnWindows(currentStatus: TmuxStatus): Promise { + this.#setSnapshot({ + phase: 'preparing', + strategy: 'wsl', + message: 'Preparing the Windows WSL tmux setup...', + detail: + 'The app can keep working without tmux, but WSL-backed tmux gives the most reliable persistent teammate path on Windows.', + error: null, + canCancel: true, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + resetLogs: true, + }); + + try { + let status = currentStatus; + + if (!status.wsl?.wslInstalled) { + status = await this.#installWindowsWslCore(); + if (!status.wsl?.wslInstalled) { + return; + } + } + + if (status.wsl?.rebootRequired) { + this.#setSnapshot({ + phase: 'needs_restart', + strategy: 'wsl', + message: 'Restart Windows before continuing with tmux setup', + detail: + status.wsl.statusDetail ?? + 'WSL was installed, but Windows still needs a restart before a distro and tmux can be configured.', + error: null, + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + }); + return; + } + + if (!status.wsl?.distroName) { + status = await this.#installWindowsDistro(); + if (!status.wsl?.distroName) { + return; + } + } + + if (status.wsl?.distroName) { + await this.#wslService.persistPreferredDistro(status.wsl.distroName); + } + + if (!status.wsl?.distroBootstrapped) { + this.#setSnapshot({ + phase: 'waiting_for_external_step', + strategy: 'wsl', + message: `Finish the first Linux setup in ${status.wsl?.distroName ?? 'your WSL distro'}`, + detail: status.wsl?.distroName + ? `Open ${status.wsl.distroName} once, create the Linux user/password, then click Re-check or Install tmux again.` + : 'Open your WSL distro once, finish the initial Linux user setup, then re-check.', + error: null, + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + }); + return; + } + + const plan = await this.#strategyResolver.resolve(); + if (!plan.capability.supported || !plan.command) { + this.#setSnapshot({ + phase: 'needs_manual_step', + strategy: plan.capability.strategy, + message: 'Automatic tmux install is not available inside WSL right now', + detail: plan.capability.reasonIfUnsupported ?? status.wsl?.statusDetail ?? null, + error: null, + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + }); + return; + } + + await this.#runResolvedPlan(plan); + } catch (error) { + if (this.#isCancelledError(error) || this.#cancelRequested) { + return; + } + this.#setSnapshot({ + phase: 'error', + strategy: 'wsl', + message: 'Windows tmux setup failed', + detail: null, + error: getErrorMessage(error), + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + }); + throw error; + } + } + + async #installWindowsWslCore(): Promise { + this.#appendLog('Starting the elevated WSL core install step...'); + this.#setSnapshot({ + phase: 'pending_external_elevation', + strategy: 'wsl', + message: 'Install WSL', + detail: + 'An administrator PowerShell window may open. Accept it to install the Windows Subsystem for Linux.', + error: null, + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + }); + + const elevationResult = await this.#windowsElevatedStepRunner.runWslCoreInstall(); + if (elevationResult.detail) { + this.#appendLog(elevationResult.detail); + } + + this.#setSnapshot({ + phase: 'waiting_for_external_step', + strategy: 'wsl', + message: 'Checking WSL after the administrator step...', + detail: 'The app is refreshing the WSL status after the elevated install flow.', + error: null, + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + }); + const status = await this.#refreshStatus(); + + if (elevationResult.outcome === 'elevated_cancelled' && !status.wsl?.wslInstalled) { + this.#setSnapshot({ + phase: 'needs_manual_step', + strategy: 'wsl', + message: 'WSL install was cancelled', + detail: + 'The administrator step was cancelled before WSL finished installing. Try again or install WSL manually, then re-check.', + error: null, + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + }); + return status; + } + + if (status.wsl?.rebootRequired) { + this.#setSnapshot({ + phase: 'needs_restart', + strategy: 'wsl', + message: 'Restart Windows before continuing with tmux setup', + detail: + status.wsl.statusDetail ?? + 'WSL was installed, but Windows still needs a restart before tmux setup can continue.', + error: null, + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + }); + return status; + } + + if (!status.wsl?.wslInstalled) { + this.#setSnapshot({ + phase: 'needs_manual_step', + strategy: 'wsl', + message: 'WSL still is not ready', + detail: + status.wsl?.statusDetail ?? + 'The app could not confirm that WSL is ready after the administrator step. Continue manually from the Microsoft WSL guide, then re-check.', + error: null, + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + }); + } + + return status; + } + + async #installWindowsDistro(): Promise { + const distroCommand = { + command: 'wsl.exe', + args: ['--install', '-d', RECOMMENDED_WSL_DISTRO_NAME, '--no-launch'], + env: process.env, + cwd: process.cwd(), + requiresPty: false, + displayCommand: `wsl --install -d ${RECOMMENDED_WSL_DISTRO_NAME} --no-launch`, + } satisfies NonNullable; + + const fallbackDistroCommand = { + command: 'wsl.exe', + args: ['--install', '--web-download', '-d', RECOMMENDED_WSL_DISTRO_NAME, '--no-launch'], + env: process.env, + cwd: process.cwd(), + requiresPty: false, + displayCommand: `wsl --install --web-download -d ${RECOMMENDED_WSL_DISTRO_NAME} --no-launch`, + } satisfies NonNullable; + + const initialResult = await this.#runCommand({ + ...distroCommand, + }); + if (initialResult.exitCode !== 0) { + this.#appendLog('Retrying WSL distro install with --web-download...'); + const fallbackResult = await this.#runCommand(fallbackDistroCommand); + if (fallbackResult.exitCode !== 0) { + this.#setSnapshot({ + phase: 'needs_manual_step', + strategy: 'wsl', + message: 'Ubuntu install needs a manual WSL step', + detail: + 'The app could not install Ubuntu automatically. Try the Microsoft WSL flow manually, then re-check.', + error: null, + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + }); + return this.#refreshStatus(); + } + } + + await this.#wslService.persistPreferredDistro(RECOMMENDED_WSL_DISTRO_NAME); + + this.#setSnapshot({ + phase: 'waiting_for_external_step', + strategy: 'wsl', + message: 'Checking the installed WSL distro...', + detail: + 'If Ubuntu was just installed, it may still need its first Linux user setup before tmux can be installed there.', + error: null, + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + }); + const status = await this.#refreshStatus(); + if (!status.wsl?.distroName) { + this.#setSnapshot({ + phase: 'needs_manual_step', + strategy: 'wsl', + message: 'WSL distro install still needs a manual step', + detail: + status.wsl?.statusDetail ?? + 'The app could not confirm that a WSL distro is ready yet. Finish the distro install manually, then re-check.', + error: null, + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + }); + return status; + } + return status; + } + + async #runResolvedPlan(plan: TmuxInstallPlan, resetLogs = true): Promise { + this.#setSnapshot({ + phase: 'preparing', + strategy: plan.capability.strategy, + message: `Preparing ${plan.capability.packageManagerLabel ?? plan.capability.strategy} install...`, + detail: null, + error: null, + canCancel: true, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + resetLogs, + }); + + const initialResult = await this.#runCommand(plan.command!); + if ( + initialResult.exitCode !== 0 && + plan.retryWithUpdateCommand && + this.#shouldRetryWithUpdate(this.#snapshot.logs) + ) { + this.#appendLog('Retrying after refreshing package metadata...'); + const updateResult = await this.#runCommand(plan.retryWithUpdateCommand); + if (updateResult.exitCode !== 0) { + throw new Error('Package metadata refresh failed'); + } + const retryResult = await this.#runCommand(plan.command!); + if (retryResult.exitCode !== 0) { + throw new Error('tmux install command failed'); + } + } else if (initialResult.exitCode !== 0) { + throw new Error('tmux install command failed'); + } + + this.#setSnapshot({ + phase: 'verifying', + strategy: plan.capability.strategy, + message: 'Verifying tmux installation...', + detail: null, + error: null, + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + }); + + const verifiedStatus = await this.#refreshStatus(); + if (!verifiedStatus.effective.runtimeReady) { + throw new Error('tmux verification failed after install'); + } + + if (verifiedStatus.platform === 'win32' && verifiedStatus.wsl?.distroName) { + await this.#wslService.persistPreferredDistro(verifiedStatus.wsl.distroName); + } + + this.#setSnapshot({ + phase: 'completed', + strategy: plan.capability.strategy, + message: 'tmux installed successfully', + detail: verifiedStatus.effective.detail, + error: null, + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + }); + } + + async #runCommand(spec: NonNullable): Promise<{ exitCode: number }> { + if (spec.requiresPty) { + this.#setSnapshot({ + phase: 'requesting_privileges', + strategy: this.#snapshot.strategy, + message: spec.displayCommand ?? [spec.command, ...spec.args].join(' '), + detail: + 'The installer is running in an interactive terminal. Enter your password below if sudo prompts for it.', + error: null, + canCancel: true, + acceptsInput: true, + inputPrompt: 'Enter password if prompted', + inputSecret: true, + }); + const result = await this.#terminalSession.run(spec, { + onLine: (line) => this.#appendLog(line), + }); + this.#throwIfCancelled(); + this.#setSnapshot({ + phase: 'installing', + strategy: this.#snapshot.strategy, + message: spec.displayCommand ?? [spec.command, ...spec.args].join(' '), + detail: 'Interactive install finished. Verifying tmux...', + error: null, + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + }); + return result; + } + + this.#setSnapshot({ + phase: 'installing', + strategy: this.#snapshot.strategy, + message: spec.displayCommand ?? [spec.command, ...spec.args].join(' '), + detail: null, + error: null, + canCancel: true, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + }); + const result = await this.#commandRunner.run(spec, { + onLine: (line) => this.#appendLog(line), + }); + this.#throwIfCancelled(); + return result; + } + + async #refreshStatus(): Promise { + this.#statusSource.invalidateStatus(); + return this.#statusSource.getStatus(); + } + + #throwIfCancelled(): void { + if (this.#cancelRequested) { + throw new TmuxInstallCancelledError(); + } + } + + #isCancelledError(error: unknown): error is TmuxInstallCancelledError { + return error instanceof TmuxInstallCancelledError; + } + + #shouldRetryWithUpdate(logs: string[]): boolean { + const combined = logs.join('\n').toLowerCase(); + return RETRY_WITH_UPDATE_PATTERNS.some((pattern) => combined.includes(pattern)); + } + + #appendLog(line: string): void { + const nextLogs = [...this.#snapshot.logs, line].slice(-MAX_LOG_LINES); + this.#setSnapshot({ + phase: this.#snapshot.phase, + strategy: this.#snapshot.strategy, + message: this.#snapshot.message, + detail: this.#snapshot.detail, + error: this.#snapshot.error, + canCancel: this.#snapshot.canCancel, + acceptsInput: this.#snapshot.acceptsInput, + inputPrompt: this.#snapshot.inputPrompt, + inputSecret: this.#snapshot.inputSecret, + logs: nextLogs, + }); + } + + #setSnapshot( + next: Omit & + Partial> & { resetLogs?: boolean } + ): void { + this.#snapshot = { + phase: next.phase, + strategy: next.strategy, + message: next.message, + detail: next.detail, + error: next.error, + canCancel: next.canCancel, + acceptsInput: next.acceptsInput, + inputPrompt: next.inputPrompt, + inputSecret: next.inputSecret, + logs: next.resetLogs ? [] : (next.logs ?? this.#snapshot.logs), + updatedAt: new Date().toISOString(), + }; + this.#presenter.present(this.#snapshot); + } +} diff --git a/src/features/tmux-installer/main/adapters/output/runtime/__tests__/TmuxInstallerRunnerAdapter.test.ts b/src/features/tmux-installer/main/adapters/output/runtime/__tests__/TmuxInstallerRunnerAdapter.test.ts new file mode 100644 index 00000000..5463e60f --- /dev/null +++ b/src/features/tmux-installer/main/adapters/output/runtime/__tests__/TmuxInstallerRunnerAdapter.test.ts @@ -0,0 +1,369 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { TmuxInstallerRunnerAdapter } from '../TmuxInstallerRunnerAdapter'; + +import type { TmuxInstallerSnapshot, TmuxStatus } from '@features/tmux-installer/contracts'; + +const CHECKED_AT = new Date().toISOString(); + +function createBaseStatus(overrides: Partial = {}): TmuxStatus { + return { + platform: 'linux', + nativeSupported: true, + checkedAt: CHECKED_AT, + host: { + available: false, + version: null, + binaryPath: null, + error: null, + }, + effective: { + available: false, + location: null, + version: null, + binaryPath: null, + runtimeReady: false, + detail: 'tmux is not installed yet.', + }, + error: null, + autoInstall: { + supported: true, + strategy: 'apt', + packageManagerLabel: 'APT', + requiresTerminalInput: false, + requiresAdmin: true, + requiresRestart: false, + mayOpenExternalWindow: false, + reasonIfUnsupported: null, + manualHints: [], + }, + ...overrides, + }; +} + +function createPresenter(): { present: ReturnType } { + return { + present: vi.fn(), + }; +} + +async function waitForSnapshot( + readSnapshot: () => TmuxInstallerSnapshot, + predicate: (snapshot: TmuxInstallerSnapshot) => boolean +): Promise { + for (let attempt = 0; attempt < 10; attempt += 1) { + const snapshot = readSnapshot(); + if (predicate(snapshot)) { + return snapshot; + } + await Promise.resolve(); + } + + return readSnapshot(); +} + +describe('TmuxInstallerRunnerAdapter', () => { + it('clears stale logs when a later install call exits early as already ready', async () => { + const presenter = createPresenter(); + const initialStatus = createBaseStatus(); + const readyStatus = createBaseStatus({ + host: { + available: true, + version: 'tmux 3.4', + binaryPath: '/usr/bin/tmux', + error: null, + }, + effective: { + available: true, + location: 'host', + version: 'tmux 3.4', + binaryPath: '/usr/bin/tmux', + runtimeReady: true, + detail: 'tmux is available for the persistent teammate runtime.', + }, + }); + let statusCallCount = 0; + const statusSource = { + getStatus: vi.fn(async () => { + statusCallCount += 1; + return statusCallCount === 1 ? initialStatus : readyStatus; + }), + invalidateStatus: vi.fn(), + }; + const commandRunner = { + run: vi.fn(async (_spec, options: { onLine: (line: string) => void }) => { + options.onLine('apt-get could not find tmux'); + return { exitCode: 1 }; + }), + cancel: vi.fn(), + }; + const runner = new TmuxInstallerRunnerAdapter( + statusSource as never, + presenter as never, + { + resolve: vi.fn(async () => ({ + capability: initialStatus.autoInstall, + command: { + command: 'sudo', + args: ['-n', 'apt-get', 'install', '-y', 'tmux'], + env: process.env, + cwd: process.cwd(), + requiresPty: false, + displayCommand: 'sudo -n apt-get install -y tmux', + }, + retryWithUpdateCommand: null, + })), + } as never, + commandRunner as never + ); + + await expect(runner.install()).rejects.toThrow('tmux install command failed'); + expect(runner.getSnapshot().logs).toContain('apt-get could not find tmux'); + + await expect(runner.install()).resolves.toBeUndefined(); + + const snapshot = runner.getSnapshot(); + expect(snapshot.phase).toBe('completed'); + expect(snapshot.logs).toEqual([]); + }); + + it('preserves leading and trailing spaces when sending installer input', async () => { + const presenter = createPresenter(); + const initialStatus = createBaseStatus(); + const verifiedStatus = createBaseStatus({ + host: { + available: true, + version: 'tmux 3.4', + binaryPath: '/usr/bin/tmux', + error: null, + }, + effective: { + available: true, + location: 'host', + version: 'tmux 3.4', + binaryPath: '/usr/bin/tmux', + runtimeReady: true, + detail: 'tmux is available for the persistent teammate runtime.', + }, + }); + let statusCallCount = 0; + const statusSource = { + getStatus: vi.fn(async () => { + statusCallCount += 1; + return statusCallCount === 1 ? initialStatus : verifiedStatus; + }), + invalidateStatus: vi.fn(), + }; + const strategyResolver = { + resolve: vi.fn(async () => ({ + capability: initialStatus.autoInstall, + command: { + command: 'sudo', + args: ['apt-get', 'install', '-y', 'tmux'], + env: process.env, + cwd: process.cwd(), + requiresPty: true, + displayCommand: 'sudo apt-get install -y tmux', + }, + retryWithUpdateCommand: null, + })), + }; + let resolveTerminalRun: ((result: { exitCode: number }) => void) | null = null; + const terminalSession = { + run: vi.fn( + () => + new Promise<{ exitCode: number }>((resolve) => { + resolveTerminalRun = resolve; + }) + ), + writeLine: vi.fn((input: string) => { + resolveTerminalRun?.({ exitCode: 0 }); + return input; + }), + cancel: vi.fn(), + }; + const runner = new TmuxInstallerRunnerAdapter( + statusSource as never, + presenter as never, + strategyResolver as never, + { run: vi.fn(), cancel: vi.fn() } as never, + terminalSession as never + ); + + const installPromise = runner.install(); + await Promise.resolve(); + await Promise.resolve(); + + expect(runner.getSnapshot().acceptsInput).toBe(true); + + await runner.submitInput(' secret with spaces '); + await expect(installPromise).resolves.toBeUndefined(); + + expect(terminalSession.writeLine).toHaveBeenCalledWith(' secret with spaces '); + }); + + it('keeps cancelled installs in cancelled state instead of overwriting them with error', async () => { + const presenter = createPresenter(); + const statusSource = { + getStatus: vi.fn(async () => createBaseStatus()), + invalidateStatus: vi.fn(), + }; + let resolveCommandRun: ((result: { exitCode: number }) => void) | null = null; + const commandRunner = { + run: vi.fn( + () => + new Promise<{ exitCode: number }>((resolve) => { + resolveCommandRun = resolve; + }) + ), + cancel: vi.fn(), + }; + const runner = new TmuxInstallerRunnerAdapter( + statusSource as never, + presenter as never, + { + resolve: vi.fn(async () => ({ + capability: createBaseStatus().autoInstall, + command: { + command: 'sudo', + args: ['-n', 'apt-get', 'install', '-y', 'tmux'], + env: process.env, + cwd: process.cwd(), + requiresPty: false, + displayCommand: 'sudo -n apt-get install -y tmux', + }, + retryWithUpdateCommand: null, + })), + } as never, + commandRunner as never + ); + + const installPromise = runner.install(); + await waitForSnapshot( + () => runner.getSnapshot(), + (snapshot) => snapshot.canCancel + ); + await runner.cancel(); + resolveCommandRun?.({ exitCode: 1 }); + + await expect(installPromise).resolves.toBeUndefined(); + + expect(commandRunner.cancel).toHaveBeenCalledOnce(); + expect(runner.getSnapshot().phase).toBe('cancelled'); + }); + + it('pins Ubuntu as the preferred distro before re-checking after WSL distro install', async () => { + const presenter = createPresenter(); + let preferredDistroName: string | null = null; + let statusCallCount = 0; + const initialStatus = createBaseStatus({ + platform: 'win32', + nativeSupported: false, + autoInstall: { + supported: true, + strategy: 'wsl', + packageManagerLabel: 'WSL', + requiresTerminalInput: false, + requiresAdmin: false, + requiresRestart: false, + mayOpenExternalWindow: true, + reasonIfUnsupported: null, + manualHints: [], + }, + wsl: { + wslInstalled: true, + rebootRequired: false, + distroName: null, + distroVersion: null, + distroBootstrapped: false, + innerPackageManager: null, + tmuxAvailableInsideWsl: false, + tmuxVersion: null, + tmuxBinaryPath: null, + statusDetail: 'No distro is configured yet.', + }, + wslPreference: null, + }); + const statusSource = { + getStatus: vi.fn(async () => { + statusCallCount += 1; + if (statusCallCount === 1) { + return initialStatus; + } + + return createBaseStatus({ + platform: 'win32', + nativeSupported: false, + autoInstall: initialStatus.autoInstall, + effective: { + available: false, + location: null, + version: null, + binaryPath: null, + runtimeReady: false, + detail: + preferredDistroName === 'Ubuntu' + ? 'Ubuntu still needs its first Linux user setup.' + : 'Debian still needs its first Linux user setup.', + }, + wsl: { + wslInstalled: true, + rebootRequired: false, + distroName: preferredDistroName === 'Ubuntu' ? 'Ubuntu' : 'Debian', + distroVersion: 2, + distroBootstrapped: false, + innerPackageManager: null, + tmuxAvailableInsideWsl: false, + tmuxVersion: null, + tmuxBinaryPath: null, + statusDetail: + preferredDistroName === 'Ubuntu' + ? 'Ubuntu still needs its first Linux user setup.' + : 'Debian still needs its first Linux user setup.', + }, + wslPreference: preferredDistroName + ? { + preferredDistroName, + source: 'persisted', + } + : null, + }); + }), + invalidateStatus: vi.fn(), + }; + const commandRunner = { + run: vi.fn(async () => ({ exitCode: 0 })), + cancel: vi.fn(), + }; + const wslService = { + persistPreferredDistro: vi.fn(async (nextPreferredDistroName: string | null) => { + preferredDistroName = nextPreferredDistroName; + }), + }; + const runner = new TmuxInstallerRunnerAdapter( + statusSource as never, + presenter as never, + { + resolve: vi.fn(async () => { + throw new Error('resolve() should not be reached before distro bootstrap completes'); + }), + } as never, + commandRunner as never, + { + run: vi.fn(), + writeLine: vi.fn(), + cancel: vi.fn(), + } as never, + wslService as never, + { + runWslCoreInstall: vi.fn(), + } as never + ); + + await expect(runner.install()).resolves.toBeUndefined(); + + expect(wslService.persistPreferredDistro).toHaveBeenCalledWith('Ubuntu'); + expect(runner.getSnapshot().phase).toBe('waiting_for_external_step'); + expect(runner.getSnapshot().message).toContain('Ubuntu'); + }); +}); diff --git a/src/features/tmux-installer/main/adapters/output/sources/TmuxStatusSourceAdapter.ts b/src/features/tmux-installer/main/adapters/output/sources/TmuxStatusSourceAdapter.ts new file mode 100644 index 00000000..fd2ae6eb --- /dev/null +++ b/src/features/tmux-installer/main/adapters/output/sources/TmuxStatusSourceAdapter.ts @@ -0,0 +1,268 @@ +import { execFile } from 'node:child_process'; + +import { buildTmuxEffectiveAvailability } from '@features/tmux-installer/core/domain/policies/buildTmuxEffectiveAvailability'; +import { TmuxInstallStrategyResolver } from '@features/tmux-installer/main/infrastructure/installer/TmuxInstallStrategyResolver'; +import { TmuxPackageManagerResolver } from '@features/tmux-installer/main/infrastructure/platform/TmuxPackageManagerResolver'; +import { TmuxPlatformResolver } from '@features/tmux-installer/main/infrastructure/platform/TmuxPlatformResolver'; +import { TmuxWslService } from '@features/tmux-installer/main/infrastructure/wsl/TmuxWslService'; +import { buildEnrichedEnv } from '@main/utils/cliEnv'; +import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; +import { getErrorMessage } from '@shared/utils/errorHandling'; + +import type { + TmuxAutoInstallCapability, + TmuxBinaryProbe, + TmuxStatus, + TmuxWslPreference, + TmuxWslStatus, +} from '@features/tmux-installer/contracts'; +import type { TmuxStatusSourcePort } from '@features/tmux-installer/core/application/ports/TmuxStatusSourcePort'; + +const STATUS_CACHE_TTL_MS = 10_000; + +export class TmuxStatusSourceAdapter implements TmuxStatusSourcePort { + readonly #platformResolver: TmuxPlatformResolver; + readonly #packageManagerResolver: TmuxPackageManagerResolver; + readonly #strategyResolver: TmuxInstallStrategyResolver; + readonly #wslService: TmuxWslService; + #cacheVersion = 0; + #cachedStatus: { value: TmuxStatus; expiresAt: number } | null = null; + #inFlightStatus: Promise | null = null; + + constructor( + platformResolver = new TmuxPlatformResolver(), + packageManagerResolver = new TmuxPackageManagerResolver(), + strategyResolver = new TmuxInstallStrategyResolver(platformResolver, packageManagerResolver), + wslService = new TmuxWslService() + ) { + this.#platformResolver = platformResolver; + this.#packageManagerResolver = packageManagerResolver; + this.#strategyResolver = strategyResolver; + this.#wslService = wslService; + } + + async getStatus(): Promise { + const cachedStatus = this.#cachedStatus; + if (cachedStatus && cachedStatus.expiresAt > Date.now()) { + return this.#cloneStatus(cachedStatus.value); + } + + if (this.#inFlightStatus) { + const status = await this.#inFlightStatus; + return this.#cloneStatus(status); + } + + const cacheVersion = this.#cacheVersion; + const statusPromise = this.#probeStatus() + .then((status) => { + if (cacheVersion === this.#cacheVersion) { + this.#cachedStatus = { + value: status, + expiresAt: Date.now() + STATUS_CACHE_TTL_MS, + }; + } + return status; + }) + .finally(() => { + if (this.#inFlightStatus === statusPromise) { + this.#inFlightStatus = null; + } + }); + + this.#inFlightStatus = statusPromise; + const status = await statusPromise; + return this.#cloneStatus(status); + } + + invalidateStatus(): void { + this.#cacheVersion += 1; + this.#cachedStatus = null; + this.#inFlightStatus = null; + } + + async #probeStatus(): Promise { + const resolvedPlatform = await this.#platformResolver.resolve(); + const checkedAt = new Date().toISOString(); + await resolveInteractiveShellEnv(); + const env = buildEnrichedEnv(); + const plan = await this.#strategyResolver.resolve(); + + const host = await this.#probeHostTmux(env, resolvedPlatform.platform); + const wslProbe = resolvedPlatform.platform === 'win32' ? await this.#wslService.probe() : null; + const effective = buildTmuxEffectiveAvailability({ + platform: resolvedPlatform.platform, + nativeSupported: resolvedPlatform.nativeSupported, + host, + wsl: wslProbe?.status ?? null, + }); + const autoInstall = this.#refineCapabilityForStatus( + resolvedPlatform.platform, + plan.capability, + wslProbe?.status ?? null, + wslProbe?.preference ?? null + ); + + return { + platform: resolvedPlatform.platform, + nativeSupported: resolvedPlatform.nativeSupported, + checkedAt, + host, + effective: { + ...effective, + detail: this.#strategyResolver.buildStatusDetail({ + platform: resolvedPlatform.platform, + effective, + autoInstall, + wsl: wslProbe?.status ?? null, + }), + }, + error: this.#resolveStatusError(host, wslProbe?.status ?? null, effective.available), + autoInstall, + wsl: wslProbe?.status ?? null, + wslPreference: wslProbe?.preference ?? null, + }; + } + + async #probeHostTmux( + env: NodeJS.ProcessEnv, + platform: TmuxStatus['platform'] + ): Promise { + try { + const { stdout, stderr } = await this.#execFileAsync('tmux', ['-V'], env, 3_000); + return { + available: true, + version: (stdout || stderr).trim() || null, + binaryPath: await this.#packageManagerResolver.resolveTmuxBinary(env, platform), + error: null, + }; + } catch (error) { + const missing = + typeof error === 'object' && + error !== null && + 'code' in error && + ((error as { code?: string }).code === 'ENOENT' || + (error as { code?: string }).code === 'ENOEXEC'); + return { + available: false, + version: null, + binaryPath: null, + error: missing ? null : getErrorMessage(error), + }; + } + } + + #resolveStatusError( + host: TmuxBinaryProbe, + wslStatus: TmuxWslStatus | null, + effectiveAvailable: boolean + ): string | null { + if (effectiveAvailable) { + return null; + } + if (wslStatus) { + return host.error ?? null; + } + return host.error ?? null; + } + + #refineCapabilityForStatus( + platform: TmuxStatus['platform'], + capability: TmuxAutoInstallCapability, + wslStatus: TmuxWslStatus | null, + preference: TmuxWslPreference | null + ): TmuxAutoInstallCapability { + if (platform !== 'win32' || capability.strategy !== 'wsl') { + return capability; + } + + const manualHints = [...capability.manualHints]; + const distroName = preference?.preferredDistroName ?? wslStatus?.distroName ?? null; + if (distroName && wslStatus?.innerPackageManager) { + const command = this.#buildWslInstallCommand(distroName, wslStatus.innerPackageManager); + if ( + !manualHints.some( + (hint) => hint.command === command || hint.title === `Install tmux in ${distroName}` + ) + ) { + manualHints.unshift({ + title: `Install tmux in ${distroName}`, + description: 'Run this from PowerShell or Windows Terminal.', + command, + }); + } + } + if (distroName && wslStatus && !wslStatus.distroBootstrapped) { + manualHints.unshift({ + title: `Open ${distroName}`, + description: 'Finish the first Linux user setup inside this WSL distro, then re-check.', + command: `wsl -d ${distroName}`, + }); + } + + return { + ...capability, + requiresRestart: Boolean(wslStatus?.rebootRequired) || capability.requiresRestart, + reasonIfUnsupported: !wslStatus?.wslInstalled + ? 'WSL is not installed yet. Install WSL first, then continue with tmux.' + : !wslStatus.distroName + ? (wslStatus.statusDetail ?? 'WSL is installed, but no Linux distro is configured yet.') + : !wslStatus.distroBootstrapped + ? `${wslStatus.distroName} still needs its first Linux user setup before tmux can be installed there.` + : capability.reasonIfUnsupported, + manualHints, + }; + } + + #buildWslInstallCommand( + distroName: string, + strategy: NonNullable + ): string { + if (strategy === 'apt') { + return `wsl -d ${distroName} -- sh -lc "sudo apt-get install -y tmux"`; + } + if (strategy === 'dnf') { + return `wsl -d ${distroName} -- sh -lc "sudo dnf install -y tmux"`; + } + if (strategy === 'yum') { + return `wsl -d ${distroName} -- sh -lc "sudo yum install -y tmux"`; + } + if (strategy === 'zypper') { + return `wsl -d ${distroName} -- sh -lc "sudo zypper --non-interactive install tmux"`; + } + if (strategy === 'pacman') { + return `wsl -d ${distroName} -- sh -lc "sudo pacman -S --noconfirm tmux"`; + } + return 'wsl -d -- sh -lc "sudo apt-get install -y tmux"'; + } + + #execFileAsync( + command: string, + args: string[], + env: NodeJS.ProcessEnv, + timeout: number + ): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + execFile(command, args, { env, timeout }, (error, stdout, stderr) => { + if (error) { + reject(error instanceof Error ? error : new Error('tmux status probe failed')); + return; + } + resolve({ stdout: String(stdout), stderr: String(stderr) }); + }); + }); + } + + #cloneStatus(status: TmuxStatus): TmuxStatus { + return { + ...status, + host: { ...status.host }, + effective: { ...status.effective }, + autoInstall: { + ...status.autoInstall, + manualHints: status.autoInstall.manualHints.map((hint) => ({ ...hint })), + }, + wsl: status.wsl ? { ...status.wsl } : status.wsl, + wslPreference: status.wslPreference ? { ...status.wslPreference } : status.wslPreference, + }; + } +} diff --git a/src/features/tmux-installer/main/adapters/output/sources/__tests__/TmuxStatusSourceAdapter.test.ts b/src/features/tmux-installer/main/adapters/output/sources/__tests__/TmuxStatusSourceAdapter.test.ts new file mode 100644 index 00000000..d93ab89e --- /dev/null +++ b/src/features/tmux-installer/main/adapters/output/sources/__tests__/TmuxStatusSourceAdapter.test.ts @@ -0,0 +1,103 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { TmuxStatusSourceAdapter } from '../TmuxStatusSourceAdapter'; + +import type { TmuxAutoInstallCapability, TmuxStatus } from '@features/tmux-installer/contracts'; + +vi.mock('node:child_process', async () => { + const actual = await vi.importActual('node:child_process'); + return { + ...actual, + execFile: vi.fn(), + }; +}); + +vi.mock('@main/utils/shellEnv', () => ({ + resolveInteractiveShellEnv: vi.fn(async () => {}), +})); + +vi.mock('@main/utils/cliEnv', () => ({ + buildEnrichedEnv: vi.fn(() => ({})), +})); + +const baseCapability: TmuxAutoInstallCapability = { + supported: true, + strategy: 'homebrew', + packageManagerLabel: 'Homebrew', + requiresTerminalInput: false, + requiresAdmin: false, + requiresRestart: false, + mayOpenExternalWindow: false, + reasonIfUnsupported: null, + manualHints: [], +}; + +describe('TmuxStatusSourceAdapter', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('does not reuse or recache a stale in-flight probe after invalidateStatus()', async () => { + const childProcess = await import('node:child_process'); + let firstCallback: + | ((error: Error | null, stdout: string | Buffer, stderr: string | Buffer) => void) + | null = null; + + const execFileMock = vi.mocked(childProcess.execFile); + execFileMock.mockImplementation( + ( + _command: string, + _args: string[], + _options: unknown, + callback: (error: Error | null, stdout: string | Buffer, stderr: string | Buffer) => void + ) => { + if (!firstCallback) { + firstCallback = callback; + return {} as never; + } + + callback(null, 'tmux second\n', ''); + return {} as never; + } + ); + + const adapter = new TmuxStatusSourceAdapter( + { + resolve: vi.fn(async () => ({ platform: 'darwin', nativeSupported: true })), + } as never, + { + resolveTmuxBinary: vi.fn(async () => '/usr/bin/tmux'), + } as never, + { + resolve: vi.fn(async () => ({ + capability: baseCapability, + command: null, + retryWithUpdateCommand: null, + })), + buildStatusDetail: vi.fn( + ({ effective }: { effective: TmuxStatus['effective'] }) => effective.detail + ), + } as never, + {} as never + ); + + const firstStatusPromise = adapter.getStatus(); + adapter.invalidateStatus(); + const secondStatus = await adapter.getStatus(); + + expect(secondStatus.host.version).toBe('tmux second'); + + firstCallback?.(null, 'tmux first\n', ''); + await firstStatusPromise; + await Promise.resolve(); + + const cachedStatus = await adapter.getStatus(); + expect(cachedStatus.host.version).toBe('tmux second'); + expect(execFileMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/features/tmux-installer/main/composition/createTmuxInstallerFeature.ts b/src/features/tmux-installer/main/composition/createTmuxInstallerFeature.ts new file mode 100644 index 00000000..68cac213 --- /dev/null +++ b/src/features/tmux-installer/main/composition/createTmuxInstallerFeature.ts @@ -0,0 +1,81 @@ +import { CancelTmuxInstallUseCase } from '@features/tmux-installer/core/application/use-cases/CancelTmuxInstallUseCase'; +import { GetTmuxInstallerSnapshotUseCase } from '@features/tmux-installer/core/application/use-cases/GetTmuxInstallerSnapshotUseCase'; +import { GetTmuxStatusUseCase } from '@features/tmux-installer/core/application/use-cases/GetTmuxStatusUseCase'; +import { InstallTmuxUseCase } from '@features/tmux-installer/core/application/use-cases/InstallTmuxUseCase'; +import { SubmitTmuxInstallerInputUseCase } from '@features/tmux-installer/core/application/use-cases/SubmitTmuxInstallerInputUseCase'; + +import { TmuxInstallerProgressPresenter } from '../adapters/output/presenters/TmuxInstallerProgressPresenter'; +import { TmuxInstallerRunnerAdapter } from '../adapters/output/runtime/TmuxInstallerRunnerAdapter'; +import { TmuxStatusSourceAdapter } from '../adapters/output/sources/TmuxStatusSourceAdapter'; + +import { invalidateTmuxRuntimeStatusCache } from './runtimeSupport'; + +import type { TmuxInstallerSnapshot, TmuxStatus } from '@features/tmux-installer/contracts'; +import type { BrowserWindow } from 'electron'; + +export interface TmuxInstallerFeatureFacade { + getStatus(): Promise; + getInstallerSnapshot(): TmuxInstallerSnapshot; + install(): Promise; + cancelInstall(): Promise; + submitInstallerInput(input: string): Promise; + invalidateStatus(): void; + setMainWindow(window: BrowserWindow | null): void; +} + +class TmuxInstallerFeatureFacadeImpl implements TmuxInstallerFeatureFacade { + readonly #presenter: TmuxInstallerProgressPresenter; + readonly #statusSource: TmuxStatusSourceAdapter; + readonly #runner: TmuxInstallerRunnerAdapter; + readonly #getStatusUseCase: GetTmuxStatusUseCase; + readonly #getSnapshotUseCase: GetTmuxInstallerSnapshotUseCase; + readonly #installUseCase: InstallTmuxUseCase; + readonly #cancelUseCase: CancelTmuxInstallUseCase; + readonly #submitInputUseCase: SubmitTmuxInstallerInputUseCase; + + constructor() { + this.#presenter = new TmuxInstallerProgressPresenter(); + this.#statusSource = new TmuxStatusSourceAdapter(); + this.#runner = new TmuxInstallerRunnerAdapter(this.#statusSource, this.#presenter); + this.#getStatusUseCase = new GetTmuxStatusUseCase(this.#statusSource); + this.#getSnapshotUseCase = new GetTmuxInstallerSnapshotUseCase(this.#runner); + this.#installUseCase = new InstallTmuxUseCase(this.#runner); + this.#cancelUseCase = new CancelTmuxInstallUseCase(this.#runner); + this.#submitInputUseCase = new SubmitTmuxInstallerInputUseCase(this.#runner); + } + + getStatus(): Promise { + return this.#getStatusUseCase.execute(); + } + + getInstallerSnapshot(): TmuxInstallerSnapshot { + return this.#getSnapshotUseCase.execute(); + } + + install(): Promise { + return this.#installUseCase.execute().finally(() => { + invalidateTmuxRuntimeStatusCache(); + }); + } + + cancelInstall(): Promise { + return this.#cancelUseCase.execute(); + } + + submitInstallerInput(input: string): Promise { + return this.#submitInputUseCase.execute(input); + } + + invalidateStatus(): void { + this.#statusSource.invalidateStatus(); + invalidateTmuxRuntimeStatusCache(); + } + + setMainWindow(window: BrowserWindow | null): void { + this.#presenter.setMainWindow(window); + } +} + +export function createTmuxInstallerFeature(): TmuxInstallerFeatureFacade { + return new TmuxInstallerFeatureFacadeImpl(); +} diff --git a/src/features/tmux-installer/main/composition/runtimeSupport.ts b/src/features/tmux-installer/main/composition/runtimeSupport.ts new file mode 100644 index 00000000..ee6c41dc --- /dev/null +++ b/src/features/tmux-installer/main/composition/runtimeSupport.ts @@ -0,0 +1,24 @@ +import { TmuxStatusSourceAdapter } from '../adapters/output/sources/TmuxStatusSourceAdapter'; +import { TmuxPlatformCommandExecutor } from '../infrastructure/runtime/TmuxPlatformCommandExecutor'; + +const runtimeStatusSource = new TmuxStatusSourceAdapter(); +const runtimeCommandExecutor = new TmuxPlatformCommandExecutor(); + +export async function isTmuxRuntimeReadyForCurrentPlatform(): Promise { + const status = await runtimeStatusSource.getStatus(); + return status.effective.available && status.effective.runtimeReady; +} + +export function invalidateTmuxRuntimeStatusCache(): void { + runtimeStatusSource.invalidateStatus(); +} + +export async function killTmuxPaneForCurrentPlatform(paneId: string): Promise { + await runtimeCommandExecutor.killPane(paneId); + invalidateTmuxRuntimeStatusCache(); +} + +export function killTmuxPaneForCurrentPlatformSync(paneId: string): void { + runtimeCommandExecutor.killPaneSync(paneId); + invalidateTmuxRuntimeStatusCache(); +} diff --git a/src/features/tmux-installer/main/index.ts b/src/features/tmux-installer/main/index.ts new file mode 100644 index 00000000..deddfc10 --- /dev/null +++ b/src/features/tmux-installer/main/index.ts @@ -0,0 +1,12 @@ +export { + registerTmuxInstallerIpc, + removeTmuxInstallerIpc, +} from './adapters/input/ipc/registerTmuxInstallerIpc'; +export type { TmuxInstallerFeatureFacade } from './composition/createTmuxInstallerFeature'; +export { createTmuxInstallerFeature } from './composition/createTmuxInstallerFeature'; +export { + invalidateTmuxRuntimeStatusCache, + isTmuxRuntimeReadyForCurrentPlatform, + killTmuxPaneForCurrentPlatform, + killTmuxPaneForCurrentPlatformSync, +} from './composition/runtimeSupport'; diff --git a/src/features/tmux-installer/main/infrastructure/installer/TmuxCommandRunner.ts b/src/features/tmux-installer/main/infrastructure/installer/TmuxCommandRunner.ts new file mode 100644 index 00000000..89e4c71a --- /dev/null +++ b/src/features/tmux-installer/main/infrastructure/installer/TmuxCommandRunner.ts @@ -0,0 +1,86 @@ +import { spawn } from 'node:child_process'; + +import { killProcessTree } from '@main/utils/childProcess'; + +import type { ChildProcessByStdio } from 'node:child_process'; +import type { Readable } from 'node:stream'; + +export interface TmuxCommandSpec { + command: string; + args: string[]; + env: NodeJS.ProcessEnv; + cwd?: string; +} + +interface RunCommandOptions { + onLine: (line: string) => void; +} + +export class TmuxCommandRunner { + #activeChild: ChildProcessByStdio | null = null; + + get activeChild(): ChildProcessByStdio | null { + return this.#activeChild; + } + + async run(spec: TmuxCommandSpec, options: RunCommandOptions): Promise<{ exitCode: number }> { + return new Promise((resolve, reject) => { + const child = spawn(spec.command, spec.args, { + cwd: spec.cwd, + env: spec.env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + this.#activeChild = child; + + const createBufferedLineWriter = (): { push: (chunk: string) => void; flush: () => void } => { + let pending = ''; + + const emitLine = (line: string): void => { + const normalizedLine = line.replace(/\r$/, ''); + if (normalizedLine.trim()) { + options.onLine(normalizedLine); + } + }; + + return { + push: (chunk: string): void => { + pending += chunk; + const lines = pending.split(/\r?\n/); + pending = lines.pop() ?? ''; + for (const line of lines) { + emitLine(line); + } + }, + flush: (): void => { + if (!pending) { + return; + } + emitLine(pending.trimEnd()); + pending = ''; + }, + }; + }; + + const stdoutWriter = createBufferedLineWriter(); + const stderrWriter = createBufferedLineWriter(); + + child.stdout.on('data', (chunk: Buffer | string) => stdoutWriter.push(String(chunk))); + child.stderr.on('data', (chunk: Buffer | string) => stderrWriter.push(String(chunk))); + child.on('error', (error) => { + this.#activeChild = null; + reject(error); + }); + child.on('close', (exitCode) => { + stdoutWriter.flush(); + stderrWriter.flush(); + this.#activeChild = null; + resolve({ exitCode: exitCode ?? 0 }); + }); + }); + } + + cancel(): void { + killProcessTree(this.#activeChild); + this.#activeChild = null; + } +} diff --git a/src/features/tmux-installer/main/infrastructure/installer/TmuxInstallStrategyResolver.ts b/src/features/tmux-installer/main/infrastructure/installer/TmuxInstallStrategyResolver.ts new file mode 100644 index 00000000..ab99dc20 --- /dev/null +++ b/src/features/tmux-installer/main/infrastructure/installer/TmuxInstallStrategyResolver.ts @@ -0,0 +1,443 @@ +import { buildTmuxAutoInstallCapability } from '@features/tmux-installer/core/domain/policies/buildTmuxAutoInstallCapability'; +import { buildEnrichedEnv } from '@main/utils/cliEnv'; +import { getShellPreferredHome, resolveInteractiveShellEnv } from '@main/utils/shellEnv'; + +import { TmuxPackageManagerResolver } from '../platform/TmuxPackageManagerResolver'; +import { TmuxPlatformResolver } from '../platform/TmuxPlatformResolver'; +import { TmuxWslService } from '../wsl/TmuxWslService'; + +import { TmuxInstallTerminalSession } from './TmuxInstallTerminalSession'; + +import type { + TmuxAutoInstallCapability, + TmuxEffectiveAvailability, + TmuxInstallStrategy, + TmuxWslStatus, +} from '@features/tmux-installer/contracts'; + +export interface TmuxInstallPlan { + capability: TmuxAutoInstallCapability; + command: { + command: string; + args: string[]; + env: NodeJS.ProcessEnv; + cwd: string; + requiresPty: boolean; + displayCommand?: string | null; + } | null; + retryWithUpdateCommand: { + command: string; + args: string[]; + env: NodeJS.ProcessEnv; + cwd: string; + requiresPty: boolean; + displayCommand?: string | null; + } | null; +} + +export class TmuxInstallStrategyResolver { + readonly #platformResolver: TmuxPlatformResolver; + readonly #packageManagerResolver: TmuxPackageManagerResolver; + readonly #wslService: TmuxWslService; + + constructor( + platformResolver = new TmuxPlatformResolver(), + packageManagerResolver = new TmuxPackageManagerResolver(), + wslService = new TmuxWslService() + ) { + this.#platformResolver = platformResolver; + this.#packageManagerResolver = packageManagerResolver; + this.#wslService = wslService; + } + + async resolve(): Promise { + await resolveInteractiveShellEnv(); + const env = buildEnrichedEnv(); + const cwd = getShellPreferredHome(); + const resolvedPlatform = await this.#platformResolver.resolve(); + + if (resolvedPlatform.platform === 'darwin') { + const manager = await this.#packageManagerResolver.resolveForMac(env); + const canRunNonInteractiveSudo = + manager.strategy === 'macports' + ? await this.#packageManagerResolver.canRunNonInteractiveSudo(env) + : true; + const interactiveTerminalAvailable = TmuxInstallTerminalSession.isSupported(); + const capability = buildTmuxAutoInstallCapability({ + platform: resolvedPlatform.platform, + strategy: manager.strategy, + packageManagerLabel: manager.label, + nonInteractivePrivilegeAvailable: canRunNonInteractiveSudo, + interactiveTerminalAvailable, + }); + return { + capability, + command: this.#buildCommand(manager.strategy, env, cwd, { + requiresPty: manager.strategy === 'macports' && !canRunNonInteractiveSudo, + }), + retryWithUpdateCommand: null, + }; + } + + if (resolvedPlatform.platform === 'linux') { + const manager = await this.#packageManagerResolver.resolveForLinux( + env, + resolvedPlatform.linux + ); + const canRunNonInteractiveSudo = + manager.strategy === 'manual' + ? false + : await this.#packageManagerResolver.canRunNonInteractiveSudo(env); + const interactiveTerminalAvailable = TmuxInstallTerminalSession.isSupported(); + const capability = buildTmuxAutoInstallCapability({ + platform: resolvedPlatform.platform, + strategy: manager.strategy, + packageManagerLabel: manager.label, + immutableHost: resolvedPlatform.linux?.immutableHost ?? false, + nonInteractivePrivilegeAvailable: canRunNonInteractiveSudo, + interactiveTerminalAvailable, + }); + return { + capability, + command: this.#buildCommand(manager.strategy, env, cwd, { + requiresPty: manager.strategy !== 'manual' && !canRunNonInteractiveSudo, + }), + retryWithUpdateCommand: + manager.strategy === 'apt' && canRunNonInteractiveSudo + ? { + command: 'sudo', + args: ['-n', 'apt-get', 'update'], + env, + cwd, + requiresPty: false, + } + : null, + }; + } + + if (resolvedPlatform.platform === 'win32') { + const wslProbe = await this.#wslService.probe(); + const interactiveTerminalAvailable = TmuxInstallTerminalSession.isSupported(); + if ( + wslProbe.status.wslInstalled && + !wslProbe.status.rebootRequired && + wslProbe.status.distroBootstrapped && + wslProbe.status.distroName && + wslProbe.status.innerPackageManager && + interactiveTerminalAvailable + ) { + return { + capability: this.#buildWindowsCapability(wslProbe.status, interactiveTerminalAvailable), + command: this.#buildWslCommand( + wslProbe.status.distroName, + wslProbe.status.innerPackageManager, + env, + cwd + ), + retryWithUpdateCommand: null, + }; + } + + return { + capability: this.#buildWindowsCapability(wslProbe.status, interactiveTerminalAvailable), + command: null, + retryWithUpdateCommand: null, + }; + } + + const capability = buildTmuxAutoInstallCapability({ + platform: resolvedPlatform.platform, + strategy: 'manual', + packageManagerLabel: null, + nonInteractivePrivilegeAvailable: false, + }); + return { + capability, + command: null, + retryWithUpdateCommand: null, + }; + } + + buildStatusDetail(input: { + platform: 'darwin' | 'linux' | 'win32' | 'unknown'; + effective: TmuxEffectiveAvailability; + autoInstall: TmuxAutoInstallCapability; + wsl: TmuxWslStatus | null; + }): string | null { + if (input.effective.detail) { + return input.effective.detail; + } + + if (input.effective.available) { + return input.effective.location === 'wsl' + ? 'tmux is available inside WSL on Windows.' + : 'tmux is available for persistent teammate runtime.'; + } + + if (input.platform === 'darwin') { + return 'You can keep using the app, but tmux improves persistent teammate reliability and restart behavior.'; + } + if (input.platform === 'linux') { + return 'You can keep using the app, but tmux improves long-running teammate stability and cleaner recovery.'; + } + if (input.platform === 'win32') { + return ( + input.wsl?.statusDetail ?? + 'You can keep using the app, but tmux on Windows goes through WSL for the best teammate experience.' + ); + } + return 'You can keep using the app, but tmux improves persistent teammate reliability.'; + } + + #buildCommand( + strategy: TmuxInstallStrategy, + env: NodeJS.ProcessEnv, + cwd: string, + options: { requiresPty: boolean } + ): TmuxInstallPlan['command'] { + if (strategy === 'homebrew') { + return { command: 'brew', args: ['install', 'tmux'], env, cwd, requiresPty: false }; + } + if (strategy === 'macports') { + return { + command: 'sudo', + args: options.requiresPty ? ['port', 'install', 'tmux'] : ['-n', 'port', 'install', 'tmux'], + env, + cwd, + requiresPty: options.requiresPty, + }; + } + if (strategy === 'apt') { + return { + command: 'sudo', + args: options.requiresPty + ? ['apt-get', 'install', '-y', 'tmux'] + : ['-n', 'apt-get', 'install', '-y', 'tmux'], + env, + cwd, + requiresPty: options.requiresPty, + }; + } + if (strategy === 'dnf') { + return { + command: 'sudo', + args: options.requiresPty + ? ['dnf', 'install', '-y', 'tmux'] + : ['-n', 'dnf', 'install', '-y', 'tmux'], + env, + cwd, + requiresPty: options.requiresPty, + }; + } + if (strategy === 'yum') { + return { + command: 'sudo', + args: options.requiresPty + ? ['yum', 'install', '-y', 'tmux'] + : ['-n', 'yum', 'install', '-y', 'tmux'], + env, + cwd, + requiresPty: options.requiresPty, + }; + } + if (strategy === 'zypper') { + return { + command: 'sudo', + args: options.requiresPty + ? ['zypper', '--non-interactive', 'install', 'tmux'] + : ['-n', 'zypper', '--non-interactive', 'install', 'tmux'], + env, + cwd, + requiresPty: options.requiresPty, + }; + } + if (strategy === 'pacman') { + return { + command: 'sudo', + args: options.requiresPty + ? ['pacman', '-S', '--noconfirm', 'tmux'] + : ['-n', 'pacman', '-S', '--noconfirm', 'tmux'], + env, + cwd, + requiresPty: options.requiresPty, + }; + } + return null; + } + + #buildWslCommand( + distroName: string, + strategy: TmuxInstallStrategy, + env: NodeJS.ProcessEnv, + cwd: string + ): TmuxInstallPlan['command'] { + return { + command: 'wsl.exe', + args: ['-d', distroName, '--', 'sh', '-lc', this.#buildWslInstallShellCommand(strategy)], + env, + cwd, + requiresPty: true, + displayCommand: this.#buildWslDisplayCommand(distroName, strategy), + }; + } + + #buildWslInstallShellCommand(strategy: TmuxInstallStrategy): string { + if (strategy === 'apt') { + return 'sudo apt-get install -y tmux'; + } + if (strategy === 'dnf') { + return 'sudo dnf install -y tmux'; + } + if (strategy === 'yum') { + return 'sudo yum install -y tmux'; + } + if (strategy === 'zypper') { + return 'sudo zypper --non-interactive install tmux'; + } + if (strategy === 'pacman') { + return 'sudo pacman -S --noconfirm tmux'; + } + return 'sudo apt-get install -y tmux'; + } + + #buildWslDisplayCommand(distroName: string, strategy: TmuxInstallStrategy): string { + return `wsl -d ${distroName} -- sh -lc "${this.#buildWslInstallShellCommand(strategy)}"`; + } + + #buildWindowsCapability( + status: TmuxWslStatus, + interactiveTerminalAvailable: boolean + ): TmuxAutoInstallCapability { + const baseCapability = buildTmuxAutoInstallCapability({ + platform: 'win32', + strategy: 'wsl', + packageManagerLabel: 'WSL', + nonInteractivePrivilegeAvailable: false, + interactiveTerminalAvailable, + }); + const manualHints = [...baseCapability.manualHints]; + + if (status.distroName && status.innerPackageManager) { + this.#prependUniqueHint(manualHints, { + title: `Install tmux in ${status.distroName}`, + description: + 'The app can run this inside WSL and forward Linux terminal input if sudo prompts for the distro password.', + command: this.#buildWslDisplayCommand(status.distroName, status.innerPackageManager), + }); + } + + if (status.wslInstalled && !status.distroName) { + this.#prependUniqueHint(manualHints, { + title: 'Install Ubuntu', + description: 'Recommended WSL distro for the tmux runtime path.', + command: 'wsl --install -d Ubuntu --no-launch', + }); + } + + if (!status.wslInstalled) { + return { + ...baseCapability, + supported: true, + requiresAdmin: true, + requiresRestart: false, + requiresTerminalInput: false, + mayOpenExternalWindow: true, + reasonIfUnsupported: null, + manualHints, + }; + } + + if (status.rebootRequired) { + return { + ...baseCapability, + supported: false, + requiresAdmin: false, + requiresRestart: true, + mayOpenExternalWindow: false, + reasonIfUnsupported: + 'WSL was installed, but Windows still needs a restart before tmux setup can continue.', + manualHints, + }; + } + + if (!status.distroName) { + return { + ...baseCapability, + supported: true, + requiresAdmin: false, + requiresRestart: false, + requiresTerminalInput: false, + mayOpenExternalWindow: true, + reasonIfUnsupported: null, + manualHints, + }; + } + + if (!status.distroBootstrapped) { + return { + ...baseCapability, + supported: false, + requiresAdmin: false, + requiresRestart: false, + requiresTerminalInput: false, + mayOpenExternalWindow: true, + reasonIfUnsupported: `${status.distroName} still needs its first Linux user setup before tmux can be installed there.`, + manualHints, + }; + } + + if (!status.innerPackageManager) { + return { + ...baseCapability, + supported: false, + requiresAdmin: false, + requiresRestart: false, + requiresTerminalInput: false, + mayOpenExternalWindow: false, + reasonIfUnsupported: `${status.distroName} is available in WSL, but the app could not determine its package manager.`, + manualHints, + }; + } + + if (!interactiveTerminalAvailable) { + return { + ...baseCapability, + supported: false, + requiresAdmin: false, + requiresRestart: false, + requiresTerminalInput: true, + mayOpenExternalWindow: false, + reasonIfUnsupported: + 'Interactive installer terminal support is unavailable in this build, so WSL tmux install must be finished manually.', + manualHints, + }; + } + + return { + ...baseCapability, + supported: true, + requiresAdmin: false, + requiresRestart: false, + requiresTerminalInput: true, + mayOpenExternalWindow: false, + reasonIfUnsupported: null, + manualHints, + }; + } + + #prependUniqueHint( + manualHints: TmuxAutoInstallCapability['manualHints'], + nextHint: TmuxAutoInstallCapability['manualHints'][number] + ): void { + if ( + manualHints.some( + (hint) => + hint.title === nextHint.title || + (hint.command && nextHint.command && hint.command === nextHint.command) + ) + ) { + return; + } + manualHints.unshift(nextHint); + } +} diff --git a/src/features/tmux-installer/main/infrastructure/installer/TmuxInstallTerminalSession.ts b/src/features/tmux-installer/main/infrastructure/installer/TmuxInstallTerminalSession.ts new file mode 100644 index 00000000..791cb393 --- /dev/null +++ b/src/features/tmux-installer/main/infrastructure/installer/TmuxInstallTerminalSession.ts @@ -0,0 +1,88 @@ +import { createLogger } from '@shared/utils/logger'; + +import type { TmuxCommandSpec } from './TmuxCommandRunner'; +import type { IPty } from 'node-pty'; +import type * as NodePty from 'node-pty'; + +const logger = createLogger('Feature:tmux-installer:pty'); + +type NodePtyModule = typeof NodePty; + +let nodePty: NodePtyModule | null = null; +try { + // eslint-disable-next-line @typescript-eslint/no-require-imports -- node-pty is optional native addon + nodePty = require('node-pty') as NodePtyModule; +} catch { + logger.warn('node-pty not available - interactive tmux installer terminal input disabled'); +} + +interface RunTerminalOptions { + onLine: (line: string) => void; + onChunk?: (chunk: string) => void; +} + +export class TmuxInstallTerminalSession { + #pty: IPty | null = null; + + static isSupported(): boolean { + return nodePty !== null; + } + + async run(spec: TmuxCommandSpec, options: RunTerminalOptions): Promise<{ exitCode: number }> { + if (!nodePty) { + throw new Error('Interactive tmux installer terminal is unavailable in this build.'); + } + + return new Promise((resolve) => { + const pty = nodePty.spawn(spec.command, spec.args, { + name: 'xterm-256color', + cols: 120, + rows: 30, + cwd: spec.cwd, + env: spec.env as Record, + }); + this.#pty = pty; + + let pending = ''; + const emitLine = (line: string): void => { + const normalized = line.replace(/\r$/, ''); + if (normalized.trim()) { + options.onLine(normalized); + } + }; + + pty.onData((chunk) => { + options.onChunk?.(chunk); + pending += chunk; + const normalizedPending = pending.replace(/\r/g, '\n'); + const lines = normalizedPending.split('\n'); + pending = lines.pop() ?? ''; + for (const line of lines) { + emitLine(line); + } + }); + pty.onExit(({ exitCode }) => { + if (pending.trim()) { + emitLine(pending.trimEnd()); + } + this.#pty = null; + resolve({ exitCode }); + }); + }); + } + + writeLine(input: string): void { + if (!this.#pty) { + throw new Error('Interactive tmux installer terminal is not running.'); + } + this.#pty.write(`${input}\r`); + } + + cancel(): void { + if (!this.#pty) { + return; + } + this.#pty.kill(); + this.#pty = null; + } +} diff --git a/src/features/tmux-installer/main/infrastructure/platform/TmuxPackageManagerResolver.ts b/src/features/tmux-installer/main/infrastructure/platform/TmuxPackageManagerResolver.ts new file mode 100644 index 00000000..48e71cca --- /dev/null +++ b/src/features/tmux-installer/main/infrastructure/platform/TmuxPackageManagerResolver.ts @@ -0,0 +1,123 @@ +import { execFile } from 'node:child_process'; + +import type { LinuxPlatformInfo } from './TmuxPlatformResolver'; +import type { TmuxInstallStrategy } from '@features/tmux-installer/contracts'; + +interface ResolveBinaryResult { + path: string | null; + label: string | null; + strategy: TmuxInstallStrategy; +} + +export class TmuxPackageManagerResolver { + async resolveForMac(env: NodeJS.ProcessEnv): Promise { + const brewPath = await this.#resolveBinary('brew', env); + if (brewPath) { + return { path: brewPath, label: 'Homebrew', strategy: 'homebrew' }; + } + + const portPath = await this.#resolveBinary('port', env); + if (portPath) { + return { path: portPath, label: 'MacPorts', strategy: 'macports' }; + } + + return { path: null, label: null, strategy: 'manual' }; + } + + async resolveForLinux( + env: NodeJS.ProcessEnv, + linuxInfo: LinuxPlatformInfo | null + ): Promise { + const preferredStrategies: { + binary: string; + label: string; + strategy: TmuxInstallStrategy; + }[] = + linuxInfo?.distroId === 'arch' + ? [{ binary: 'pacman', label: 'Pacman', strategy: 'pacman' }] + : linuxInfo?.distroId === 'fedora' + ? [{ binary: 'dnf', label: 'DNF', strategy: 'dnf' }] + : linuxInfo?.distroId === 'opensuse-tumbleweed' || + linuxInfo?.distroId === 'opensuse-leap' || + linuxInfo?.distroId === 'sles' + ? [{ binary: 'zypper', label: 'Zypper', strategy: 'zypper' }] + : [{ binary: 'apt-get', label: 'APT', strategy: 'apt' }]; + + const candidates = [ + ...preferredStrategies, + { binary: 'apt-get', label: 'APT', strategy: 'apt' as const }, + { binary: 'dnf', label: 'DNF', strategy: 'dnf' as const }, + { binary: 'yum', label: 'YUM', strategy: 'yum' as const }, + { binary: 'zypper', label: 'Zypper', strategy: 'zypper' as const }, + { binary: 'pacman', label: 'Pacman', strategy: 'pacman' as const }, + ]; + + for (const candidate of candidates) { + const binaryPath = await this.#resolveBinary(candidate.binary, env); + if (binaryPath) { + return { path: binaryPath, label: candidate.label, strategy: candidate.strategy }; + } + } + + return { path: null, label: null, strategy: 'manual' }; + } + + async resolveTmuxBinary( + env: NodeJS.ProcessEnv, + platform: 'darwin' | 'linux' | 'win32' | 'unknown' + ): Promise { + const locator = platform === 'win32' ? 'where' : 'which'; + return this.#resolveBinaryWithLocator(locator, 'tmux', env); + } + + async canRunNonInteractiveSudo(env: NodeJS.ProcessEnv): Promise { + try { + await this.#execFileAsync('sudo', ['-n', 'true'], env, 2_000); + return true; + } catch { + return false; + } + } + + async #resolveBinary(command: string, env: NodeJS.ProcessEnv): Promise { + return this.#resolveBinaryWithLocator( + process.platform === 'win32' ? 'where' : 'which', + command, + env + ); + } + + async #resolveBinaryWithLocator( + locator: string, + command: string, + env: NodeJS.ProcessEnv + ): Promise { + try { + const { stdout } = await this.#execFileAsync(locator, [command], env, 2_000); + const firstLine = stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .find(Boolean); + return firstLine ?? null; + } catch { + return null; + } + } + + #execFileAsync( + command: string, + args: string[], + env: NodeJS.ProcessEnv, + timeout: number + ): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + execFile(command, args, { env, timeout }, (error, stdout, stderr) => { + if (error) { + reject(error instanceof Error ? error : new Error(`Failed to run locator ${command}`)); + return; + } + resolve({ stdout: String(stdout), stderr: String(stderr) }); + }); + }); + } +} diff --git a/src/features/tmux-installer/main/infrastructure/platform/TmuxPlatformResolver.ts b/src/features/tmux-installer/main/infrastructure/platform/TmuxPlatformResolver.ts new file mode 100644 index 00000000..d691caf3 --- /dev/null +++ b/src/features/tmux-installer/main/infrastructure/platform/TmuxPlatformResolver.ts @@ -0,0 +1,72 @@ +import { promises as fsp } from 'node:fs'; + +import type { TmuxPlatform } from '@features/tmux-installer/contracts'; + +export interface LinuxPlatformInfo { + distroId: string | null; + immutableHost: boolean; +} + +export interface ResolvedTmuxPlatform { + platform: TmuxPlatform; + nativeSupported: boolean; + linux: LinuxPlatformInfo | null; +} + +export class TmuxPlatformResolver { + async resolve(): Promise { + const platform = this.#mapPlatform(process.platform); + if (platform !== 'linux') { + return { + platform, + nativeSupported: platform === 'darwin', + linux: null, + }; + } + + return { + platform, + nativeSupported: true, + linux: await this.#resolveLinuxInfo(), + }; + } + + #mapPlatform(platform: NodeJS.Platform): TmuxPlatform { + if (platform === 'darwin' || platform === 'linux' || platform === 'win32') { + return platform; + } + return 'unknown'; + } + + async #resolveLinuxInfo(): Promise { + let distroId: string | null = null; + try { + const content = await fsp.readFile('/etc/os-release', 'utf8'); + distroId = + content + .split('\n') + .map((line) => line.trim()) + .find((line) => line.startsWith('ID=')) + ?.slice(3) + .replace(/(^"|"$)/g, '') ?? null; + } catch { + distroId = null; + } + + const immutableHost = + (await this.#exists('/run/ostree-booted')) || + (await this.#exists('/usr/bin/rpm-ostree')) || + distroId === 'opensuse-microos'; + + return { distroId, immutableHost }; + } + + async #exists(path: string): Promise { + try { + await fsp.access(path); + return true; + } catch { + return false; + } + } +} diff --git a/src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts b/src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts new file mode 100644 index 00000000..948c86ba --- /dev/null +++ b/src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts @@ -0,0 +1,109 @@ +import { execFile, execFileSync } from 'node:child_process'; + +import { buildEnrichedEnv } from '@main/utils/cliEnv'; +import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; + +import { TmuxPackageManagerResolver } from '../platform/TmuxPackageManagerResolver'; +import { TmuxWslService } from '../wsl/TmuxWslService'; + +interface ExecResult { + exitCode: number; + stdout: string; + stderr: string; +} + +export class TmuxPlatformCommandExecutor { + readonly #wslService: TmuxWslService; + readonly #packageManagerResolver: TmuxPackageManagerResolver; + + constructor( + wslService = new TmuxWslService(), + packageManagerResolver = new TmuxPackageManagerResolver() + ) { + this.#wslService = wslService; + this.#packageManagerResolver = packageManagerResolver; + } + + async execTmux(args: string[], timeout = 5_000): Promise { + if (process.platform === 'win32') { + return this.#wslService.execTmux(args, null, timeout); + } + + await resolveInteractiveShellEnv(); + const env = buildEnrichedEnv(); + const executable = await this.#resolveNativeTmuxExecutable(env); + return new Promise((resolve) => { + execFile(executable, args, { env, timeout }, (error, stdout, stderr) => { + const errorCode = + typeof error === 'object' && error !== null && 'code' in error + ? (error as NodeJS.ErrnoException).code + : undefined; + resolve({ + exitCode: typeof errorCode === 'number' ? errorCode : error ? 1 : 0, + stdout: String(stdout), + stderr: String(stderr) || (error instanceof Error ? error.message : ''), + }); + }); + }); + } + + async killPane(paneId: string): Promise { + const result = await this.execTmux(['kill-pane', '-t', paneId], 3_000); + if (result.exitCode !== 0) { + throw new Error(result.stderr || `Failed to kill tmux pane ${paneId}`); + } + } + + killPaneSync(paneId: string): void { + if (process.platform === 'win32') { + const preferredDistro = this.#wslService.getPersistedPreferredDistroSync(); + const candidates = this.#getWslExecutableCandidates(); + let lastError: Error | null = null; + const distroAttempts = preferredDistro ? [preferredDistro, null] : [null]; + for (const distroName of distroAttempts) { + for (const executable of candidates) { + try { + execFileSync( + executable, + [...(distroName ? ['-d', distroName] : []), '-e', 'tmux', 'kill-pane', '-t', paneId], + { + stdio: 'ignore', + windowsHide: true, + } + ); + return; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + } + } + } + throw lastError ?? new Error(`Failed to kill tmux pane ${paneId}`); + } + + // eslint-disable-next-line sonarjs/no-os-command-from-path -- tmux is resolved during runtime readiness checks before this sync cleanup path is used + execFileSync('tmux', ['kill-pane', '-t', paneId], { stdio: 'ignore' }); + } + + #getWslExecutableCandidates(): string[] { + const candidates = new Set(); + const windir = process.env.WINDIR; + if (windir) { + candidates.add(`${windir}\\System32\\wsl.exe`); + candidates.add(`${windir}\\Sysnative\\wsl.exe`); + } + candidates.add('wsl.exe'); + return [...candidates]; + } + + async #resolveNativeTmuxExecutable(env: NodeJS.ProcessEnv): Promise { + const platform = + process.platform === 'darwin' || process.platform === 'linux' || process.platform === 'win32' + ? process.platform + : 'unknown'; + const executable = await this.#packageManagerResolver.resolveTmuxBinary(env, platform); + if (!executable) { + throw new Error('tmux executable could not be resolved for the current platform.'); + } + return executable; + } +} diff --git a/src/features/tmux-installer/main/infrastructure/runtime/__tests__/TmuxPlatformCommandExecutor.test.ts b/src/features/tmux-installer/main/infrastructure/runtime/__tests__/TmuxPlatformCommandExecutor.test.ts new file mode 100644 index 00000000..f89028e4 --- /dev/null +++ b/src/features/tmux-installer/main/infrastructure/runtime/__tests__/TmuxPlatformCommandExecutor.test.ts @@ -0,0 +1,71 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, type Mock,vi } from 'vitest'; + +vi.mock('node:child_process', async () => { + const actual = await vi.importActual('node:child_process'); + return { + ...actual, + execFile: vi.fn(), + execFileSync: vi.fn(), + }; +}); + +import * as childProcess from 'node:child_process'; + +import { TmuxPlatformCommandExecutor } from '../TmuxPlatformCommandExecutor'; + +function setPlatform(value: string): void { + Object.defineProperty(process, 'platform', { + value, + configurable: true, + writable: true, + }); +} + +const originalPlatform = process.platform; +const originalWindir = process.env.WINDIR; + +describe('TmuxPlatformCommandExecutor', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + setPlatform(originalPlatform); + if (originalWindir === undefined) { + delete process.env.WINDIR; + } else { + process.env.WINDIR = originalWindir; + } + }); + + it('falls back to plain wsl.exe for sync cleanup when WINDIR is missing', () => { + setPlatform('win32'); + delete process.env.WINDIR; + + const execFileSyncMock = childProcess.execFileSync as unknown as Mock; + execFileSyncMock.mockImplementation((command: string) => { + if (command === 'wsl.exe') { + return Buffer.from(''); + } + throw new Error(`Unexpected command: ${command}`); + }); + + const executor = new TmuxPlatformCommandExecutor( + { + getPersistedPreferredDistroSync: () => null, + } as never, + {} as never + ); + + expect(() => executor.killPaneSync('%1')).not.toThrow(); + expect(execFileSyncMock).toHaveBeenCalledWith( + 'wsl.exe', + ['-e', 'tmux', 'kill-pane', '-t', '%1'], + expect.objectContaining({ + stdio: 'ignore', + windowsHide: true, + }) + ); + }); +}); diff --git a/src/features/tmux-installer/main/infrastructure/wsl/TmuxWslPreferenceStore.ts b/src/features/tmux-installer/main/infrastructure/wsl/TmuxWslPreferenceStore.ts new file mode 100644 index 00000000..d2d7326f --- /dev/null +++ b/src/features/tmux-installer/main/infrastructure/wsl/TmuxWslPreferenceStore.ts @@ -0,0 +1,79 @@ +import { mkdirSync, readFileSync } from 'node:fs'; +import * as fsp from 'node:fs/promises'; +import path from 'node:path'; + +import { app } from 'electron'; + +interface PersistedTmuxWslPreference { + preferredDistroName?: unknown; +} + +type ResolveUserDataPath = () => string; + +export class TmuxWslPreferenceStore { + readonly #resolveUserDataPath: ResolveUserDataPath; + + constructor(resolveUserDataPath: ResolveUserDataPath = () => app.getPath('userData')) { + this.#resolveUserDataPath = resolveUserDataPath; + } + + async getPreferredDistro(): Promise { + try { + const raw = await fsp.readFile(this.#getFilePath(), 'utf8'); + return this.#parsePreferredDistro(raw); + } catch { + return null; + } + } + + getPreferredDistroSync(): string | null { + try { + const raw = readFileSync(this.#getFilePath(), 'utf8'); + return this.#parsePreferredDistro(raw); + } catch { + return null; + } + } + + async setPreferredDistro(preferredDistroName: string): Promise { + const nextValue = preferredDistroName.trim(); + if (!nextValue) { + await this.clearPreferredDistro(); + return; + } + + const filePath = this.#getFilePath(); + await fsp.mkdir(path.dirname(filePath), { recursive: true }); + await fsp.writeFile( + filePath, + JSON.stringify({ preferredDistroName: nextValue }, null, 2), + 'utf8' + ); + } + + async clearPreferredDistro(): Promise { + try { + await fsp.unlink(this.#getFilePath()); + } catch { + // ignore missing file + } + } + + #getFilePath(): string { + const userDataPath = this.#resolveUserDataPath(); + const dirPath = path.join(userDataPath, 'tmux-installer'); + mkdirSync(dirPath, { recursive: true }); + return path.join(dirPath, 'wsl-preference.json'); + } + + #parsePreferredDistro(raw: string): string | null { + try { + const parsed = JSON.parse(raw) as PersistedTmuxWslPreference; + return typeof parsed.preferredDistroName === 'string' && parsed.preferredDistroName.trim() + ? parsed.preferredDistroName.trim() + : null; + } catch { + return null; + } + } +} diff --git a/src/features/tmux-installer/main/infrastructure/wsl/TmuxWslService.ts b/src/features/tmux-installer/main/infrastructure/wsl/TmuxWslService.ts new file mode 100644 index 00000000..3741d985 --- /dev/null +++ b/src/features/tmux-installer/main/infrastructure/wsl/TmuxWslService.ts @@ -0,0 +1,481 @@ +import { execFile } from 'node:child_process'; +import path from 'node:path'; + +import { TmuxWslPreferenceStore } from './TmuxWslPreferenceStore'; + +import type { + TmuxInstallStrategy, + TmuxWslPreference, + TmuxWslStatus, +} from '@features/tmux-installer/contracts'; + +interface ExecWslResult { + exitCode: number; + stdout: string; + stderr: string; +} + +interface WslVerboseDistroEntry { + name: string; + isDefault: boolean; + version: 1 | 2 | null; +} + +type ExecFileCallback = ( + error: Error | null, + stdout: string | Buffer, + stderr: string | Buffer +) => void; + +type ExecFileLike = ( + command: string, + args: string[], + options: { + timeout: number; + windowsHide: boolean; + maxBuffer: number; + encoding: 'buffer'; + }, + callback: ExecFileCallback +) => void; + +export interface TmuxWslProbeResult { + preference: TmuxWslPreference | null; + status: TmuxWslStatus; +} + +const MAX_BUFFER_BYTES = 1024 * 1024; +const WSL_NOT_AVAILABLE_DETAIL = 'WSL is not available on this Windows machine yet.'; + +export class TmuxWslService { + readonly #execFile: ExecFileLike; + readonly #preferenceStore: TmuxWslPreferenceStore; + + constructor( + execFileImpl: ExecFileLike = execFile as ExecFileLike, + preferenceStore = new TmuxWslPreferenceStore() + ) { + this.#execFile = execFileImpl; + this.#preferenceStore = preferenceStore; + } + + async probe(): Promise { + const statusProbe = await this.#run(['--status'], 4_000); + const distroListProbe = await this.#run(['--list', '--quiet'], 4_000); + const persistedPreferredDistro = await this.#preferenceStore.getPreferredDistro(); + const wslInstalled = statusProbe.exitCode === 0 || distroListProbe.exitCode === 0; + const rebootRequired = this.#looksLikeRestartRequired( + `${statusProbe.stdout}\n${statusProbe.stderr}` + ); + + if (!wslInstalled) { + if (persistedPreferredDistro) { + await this.#preferenceStore.clearPreferredDistro(); + } + return { + preference: null, + status: { + wslInstalled: false, + rebootRequired, + distroName: null, + distroVersion: null, + distroBootstrapped: false, + innerPackageManager: null, + tmuxAvailableInsideWsl: false, + tmuxVersion: null, + tmuxBinaryPath: null, + statusDetail: this.#firstNonEmpty( + statusProbe.stderr, + statusProbe.stdout, + WSL_NOT_AVAILABLE_DETAIL + ), + }, + }; + } + + const distros = this.#parseWslDistros(distroListProbe.stdout); + if (distros.length === 0) { + if (persistedPreferredDistro) { + await this.#preferenceStore.clearPreferredDistro(); + } + return { + preference: null, + status: { + wslInstalled: true, + rebootRequired, + distroName: null, + distroVersion: null, + distroBootstrapped: false, + innerPackageManager: null, + tmuxAvailableInsideWsl: false, + tmuxVersion: null, + tmuxBinaryPath: null, + statusDetail: rebootRequired + ? 'WSL was installed, but Windows still needs a restart before a Linux distro can be configured.' + : 'WSL is available, but no Linux distribution is installed yet.', + }, + }; + } + + const verboseProbe = await this.#run(['--list', '--verbose'], 4_000); + const verboseEntries = this.#parseVerboseDistroEntries(verboseProbe.stdout, distros); + const preferredDistro = this.#resolvePreferredDistro({ + distros, + verboseEntries, + persistedPreferredDistro, + }); + const usingPersistedPreference = + Boolean(persistedPreferredDistro) && preferredDistro === persistedPreferredDistro; + if (persistedPreferredDistro && preferredDistro !== persistedPreferredDistro) { + await this.#preferenceStore.clearPreferredDistro(); + } + const preferredVersion = + verboseEntries.find((entry) => entry.name === preferredDistro)?.version ?? null; + + if (!preferredDistro) { + return { + preference: { + preferredDistroName: null, + source: usingPersistedPreference ? 'persisted' : null, + }, + status: { + wslInstalled: true, + rebootRequired, + distroName: null, + distroVersion: null, + distroBootstrapped: false, + innerPackageManager: null, + tmuxAvailableInsideWsl: false, + tmuxVersion: null, + tmuxBinaryPath: null, + statusDetail: + distros.length > 1 + ? 'WSL has multiple Linux distributions, but no default or saved distro target is configured yet.' + : 'WSL is available, but the app could not determine which Linux distribution to target.', + }, + }; + } + + const preference: TmuxWslPreference = { + preferredDistroName: preferredDistro, + source: usingPersistedPreference + ? 'persisted' + : verboseEntries.some((entry) => entry.isDefault) + ? 'default' + : 'manual', + }; + + const bootstrapProbe = await this.#run( + ['-d', preferredDistro, '--', 'sh', '-lc', 'printf ready'], + 5_000 + ); + const distroBootstrapped = bootstrapProbe.exitCode === 0; + if (!distroBootstrapped) { + return { + preference, + status: { + wslInstalled: true, + rebootRequired, + distroName: preferredDistro, + distroVersion: preferredVersion, + distroBootstrapped: false, + innerPackageManager: null, + tmuxAvailableInsideWsl: false, + tmuxVersion: null, + tmuxBinaryPath: null, + statusDetail: this.#firstNonEmpty( + bootstrapProbe.stderr, + bootstrapProbe.stdout, + `${preferredDistro} is installed in WSL, but its first Linux user setup is not finished yet. Open it once, complete the setup, then re-check.` + ), + }, + }; + } + + const innerPackageManager = await this.#resolveInnerPackageManager(preferredDistro); + const tmuxProbe = await this.#run( + [ + '-d', + preferredDistro, + '--', + 'sh', + '-lc', + 'command -v tmux >/dev/null 2>&1 && { tmux -V; printf "\\n"; command -v tmux; }', + ], + 5_000 + ); + const tmuxLines = tmuxProbe.stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + + return { + preference, + status: { + wslInstalled: true, + rebootRequired, + distroName: preferredDistro, + distroVersion: preferredVersion, + distroBootstrapped: true, + innerPackageManager, + tmuxAvailableInsideWsl: tmuxProbe.exitCode === 0, + tmuxVersion: tmuxProbe.exitCode === 0 ? (tmuxLines[0] ?? null) : null, + tmuxBinaryPath: tmuxProbe.exitCode === 0 ? (tmuxLines[1] ?? null) : null, + statusDetail: + tmuxProbe.exitCode === 0 + ? `tmux is available inside ${preferredDistro} on Windows through WSL.` + : `tmux is not installed inside the ${preferredDistro} WSL distro yet.`, + }, + }; + } + + async execTmux( + args: string[], + preferredDistroName?: string | null, + timeout = 5_000 + ): Promise { + const distroName = preferredDistroName ?? (await this.probe()).preference?.preferredDistroName; + if (!distroName) { + return { + exitCode: 1, + stdout: '', + stderr: 'No WSL distribution is available for tmux.', + }; + } + + return this.#run(['-d', distroName, '-e', 'tmux', ...args], timeout); + } + + getPersistedPreferredDistroSync(): string | null { + return this.#preferenceStore.getPreferredDistroSync(); + } + + async persistPreferredDistro(preferredDistroName: string | null): Promise { + if (!preferredDistroName?.trim()) { + await this.#preferenceStore.clearPreferredDistro(); + return; + } + await this.#preferenceStore.setPreferredDistro(preferredDistroName); + } + + async #resolveInnerPackageManager(distro: string): Promise { + const distroIdProbe = await this.#run( + ['-d', distro, '--', 'sh', '-lc', '. /etc/os-release >/dev/null 2>&1 && printf %s "$ID"'], + 4_000 + ); + const distroId = distroIdProbe.stdout.trim().toLowerCase(); + if (distroId === 'arch') { + return 'pacman'; + } + if (distroId === 'fedora') { + return 'dnf'; + } + if ( + distroId === 'ubuntu' || + distroId === 'debian' || + distroId === 'pop' || + distroId === 'linuxmint' || + distroId === 'kali' + ) { + return 'apt'; + } + if (distroId === 'opensuse-tumbleweed' || distroId === 'opensuse-leap' || distroId === 'sles') { + return 'zypper'; + } + + const candidateChecks: { binary: string; strategy: TmuxInstallStrategy }[] = [ + { binary: 'apt-get', strategy: 'apt' }, + { binary: 'dnf', strategy: 'dnf' }, + { binary: 'yum', strategy: 'yum' }, + { binary: 'zypper', strategy: 'zypper' }, + { binary: 'pacman', strategy: 'pacman' }, + ]; + + for (const candidate of candidateChecks) { + const probe = await this.#run( + ['-d', distro, '--', 'sh', '-lc', `command -v ${candidate.binary} >/dev/null 2>&1`], + 3_000 + ); + if (probe.exitCode === 0) { + return candidate.strategy; + } + } + + return null; + } + + async #run(args: string[], timeout: number): Promise { + const candidates = this.#getExecutableCandidates(); + let lastFailure: ExecWslResult | null = null; + + for (const executable of candidates) { + const result = await this.#exec(executable, args, timeout); + if (result === null) { + continue; + } + lastFailure = result; + if (result.exitCode === 0) { + return result; + } + if (result.exitCode !== 0) { + return result; + } + } + + return ( + lastFailure ?? { + exitCode: 1, + stdout: '', + stderr: WSL_NOT_AVAILABLE_DETAIL, + } + ); + } + + async #exec(executable: string, args: string[], timeout: number): Promise { + return new Promise((resolve) => { + this.#execFile( + executable, + args, + { + timeout, + windowsHide: true, + maxBuffer: MAX_BUFFER_BYTES, + encoding: 'buffer', + }, + (error, stdout, stderr) => { + const errorCode = + typeof error === 'object' && error !== null && 'code' in error + ? (error as NodeJS.ErrnoException).code + : undefined; + if (errorCode === 'ENOENT') { + resolve(null); + return; + } + resolve({ + exitCode: typeof errorCode === 'number' ? errorCode : error ? 1 : 0, + stdout: this.#decodeOutput(stdout), + stderr: this.#decodeOutput(stderr) || (error instanceof Error ? error.message : ''), + }); + } + ); + }); + } + + #getExecutableCandidates(): string[] { + const candidates = new Set(); + const windir = process.env.WINDIR; + if (windir) { + candidates.add(path.join(windir, 'System32', 'wsl.exe')); + candidates.add(path.join(windir, 'Sysnative', 'wsl.exe')); + } + candidates.add('wsl.exe'); + return [...candidates]; + } + + #decodeOutput(output: string | Buffer): string { + if (typeof output === 'string') { + return output.replace(/\0/g, ''); + } + if (output.length === 0) { + return ''; + } + + const hasUtf16LeBom = output.length >= 2 && output[0] === 0xff && output[1] === 0xfe; + const decoded = + hasUtf16LeBom || this.#looksLikeUtf16Le(output) + ? output.toString('utf16le') + : output.toString('utf8'); + return decoded.replace(/\0/g, ''); + } + + #looksLikeUtf16Le(buffer: Buffer): boolean { + const sampleSize = Math.min(buffer.length, 512); + if (sampleSize < 2) { + return false; + } + + let pairs = 0; + let nullsAtOddIndex = 0; + for (let i = 0; i + 1 < sampleSize; i += 2) { + pairs += 1; + if (buffer[i + 1] === 0) { + nullsAtOddIndex += 1; + } + } + + return pairs > 0 && nullsAtOddIndex / pairs >= 0.3; + } + + #parseWslDistros(stdout: string): string[] { + return stdout + .split(/\r?\n/) + .map((line) => line.replace(/\0/g, '').trim()) + .map((line) => line.replace(/^\*\s*/, '').trim()) + .filter(Boolean); + } + + #parseVerboseDistroEntries(stdout: string, distros: string[]): WslVerboseDistroEntry[] { + const sortedDistros = [...distros].sort((left, right) => right.length - left.length); + const entries: WslVerboseDistroEntry[] = []; + + for (const rawLine of stdout.split(/\r?\n/)) { + let line = rawLine.replace(/\0/g, '').trim(); + if (!line) { + continue; + } + + const isDefault = line.startsWith('*'); + if (isDefault) { + line = line.slice(1).trim(); + } + + const matchedName = sortedDistros.find((distro) => line.startsWith(distro)); + if (!matchedName) { + continue; + } + + const lineTokens = line.split(/\s+/); + const versionToken = lineTokens[lineTokens.length - 1]; + const version = versionToken === '1' ? 1 : versionToken === '2' ? 2 : null; + entries.push({ name: matchedName, isDefault, version }); + } + + return entries; + } + + #resolvePreferredDistro(input: { + distros: string[]; + verboseEntries: WslVerboseDistroEntry[]; + persistedPreferredDistro: string | null; + }): string | null { + if (input.persistedPreferredDistro && input.distros.includes(input.persistedPreferredDistro)) { + return input.persistedPreferredDistro; + } + + const defaultDistro = input.verboseEntries.find((entry) => entry.isDefault)?.name ?? null; + if (defaultDistro) { + return defaultDistro; + } + + if (input.distros.length === 1) { + return input.distros[0] ?? null; + } + + return null; + } + + #looksLikeRestartRequired(output: string): boolean { + const lowered = output.toLowerCase(); + return lowered.includes('restart') || lowered.includes('reboot'); + } + + #firstNonEmpty(...values: (string | null | undefined)[]): string { + for (const value of values) { + const trimmed = value?.trim(); + if (trimmed) { + return trimmed; + } + } + return WSL_NOT_AVAILABLE_DETAIL; + } +} diff --git a/src/features/tmux-installer/main/infrastructure/wsl/WindowsElevatedStepRunner.ts b/src/features/tmux-installer/main/infrastructure/wsl/WindowsElevatedStepRunner.ts new file mode 100644 index 00000000..c5f7734e --- /dev/null +++ b/src/features/tmux-installer/main/infrastructure/wsl/WindowsElevatedStepRunner.ts @@ -0,0 +1,214 @@ +import { execFile } from 'node:child_process'; +import * as fsp from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { createLogger } from '@shared/utils/logger'; + +const logger = createLogger('Feature:tmux-installer:windows-elevation'); + +interface ExecResult { + exitCode: number; + stdout: string; + stderr: string; +} + +interface PersistedElevationResult { + ok?: boolean; + detail?: string | null; +} + +type ExecFileCallback = ( + error: Error | null, + stdout: string | Buffer, + stderr: string | Buffer +) => void; + +type ExecFileLike = ( + command: string, + args: string[], + options: { + timeout: number; + windowsHide: boolean; + maxBuffer: number; + }, + callback: ExecFileCallback +) => void; + +type MakeTempDir = (prefix: string) => Promise; + +export interface WindowsElevatedStepResult { + outcome: + | 'elevated_succeeded' + | 'elevated_cancelled' + | 'elevated_failed' + | 'elevated_unknown_outcome'; + detail: string | null; + resultFilePath: string | null; +} + +const MAX_BUFFER_BYTES = 512 * 1024; + +export class WindowsElevatedStepRunner { + readonly #execFile: ExecFileLike; + readonly #makeTempDir: MakeTempDir; + + constructor( + execFileImpl: ExecFileLike = execFile as ExecFileLike, + makeTempDir: MakeTempDir = (prefix) => fsp.mkdtemp(path.join(tmpdir(), prefix)) + ) { + this.#execFile = execFileImpl; + this.#makeTempDir = makeTempDir; + } + + async runWslCoreInstall(): Promise { + const tempDir = await this.#makeTempDir('tmux-wsl-install-'); + const resultFilePath = path.join(tempDir, 'result.json'); + const helperScriptPath = path.join(tempDir, 'run-wsl-core-install.ps1'); + const launcherScriptPath = path.join(tempDir, 'launch-wsl-core-install.ps1'); + + await fsp.writeFile( + helperScriptPath, + this.#buildHelperScript(resultFilePath, ['--install', '--no-distribution']), + 'utf8' + ); + await fsp.writeFile(launcherScriptPath, this.#buildLauncherScript(helperScriptPath), 'utf8'); + + const result = await this.#execPowerShellFile(launcherScriptPath, 30 * 60 * 1_000); + const persistedResult = await this.#readPersistedResult(resultFilePath); + + if (persistedResult) { + return { + outcome: persistedResult.ok ? 'elevated_succeeded' : 'elevated_failed', + detail: persistedResult.detail ?? null, + resultFilePath, + }; + } + + if (this.#looksLikeElevationCancelled(result)) { + return { + outcome: 'elevated_cancelled', + detail: 'Administrator permission request was cancelled.', + resultFilePath: null, + }; + } + + logger.warn('Windows elevated WSL core install finished without a result file', { + exitCode: result.exitCode, + stderr: result.stderr, + }); + return { + outcome: 'elevated_unknown_outcome', + detail: this.#firstNonEmpty(result.stderr, result.stdout), + resultFilePath: null, + }; + } + + async #execPowerShellFile(scriptPath: string, timeout: number): Promise { + return new Promise((resolve) => { + this.#execFile( + 'powershell.exe', + ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath], + { + timeout, + windowsHide: true, + maxBuffer: MAX_BUFFER_BYTES, + }, + (error, stdout, stderr) => { + const errorCode = + typeof error === 'object' && error !== null && 'code' in error + ? (error as NodeJS.ErrnoException).code + : undefined; + resolve({ + exitCode: typeof errorCode === 'number' ? errorCode : error ? 1 : 0, + stdout: String(stdout), + stderr: String(stderr) || (error instanceof Error ? error.message : ''), + }); + } + ); + }); + } + + async #readPersistedResult(resultFilePath: string): Promise { + try { + const raw = await fsp.readFile(resultFilePath, 'utf8'); + return JSON.parse(this.#stripBom(raw)) as PersistedElevationResult; + } catch { + return null; + } + } + + #buildLauncherScript(helperScriptPath: string): string { + const escapedHelperPath = this.#escapePowerShellSingleQuotedString(helperScriptPath); + return [ + '$ErrorActionPreference = "Stop"', + `$helperScript = '${escapedHelperPath}'`, + '$argumentList = @(', + " '-NoProfile',", + " '-ExecutionPolicy',", + " 'Bypass',", + " '-File',", + ' $helperScript', + ')', + "Start-Process -FilePath 'powershell.exe' -Verb RunAs -Wait -ArgumentList $argumentList", + '', + ].join('\n'); + } + + #buildHelperScript(resultFilePath: string, wslArgs: string[]): string { + const escapedResultFilePath = this.#escapePowerShellSingleQuotedString(resultFilePath); + const quotedArgs = wslArgs + .map((arg) => `'${this.#escapePowerShellSingleQuotedString(arg)}'`) + .join(', '); + return [ + '$ErrorActionPreference = "Stop"', + `$resultFile = '${escapedResultFilePath}'`, + `$wslArgs = @(${quotedArgs})`, + '$result = @{ ok = $false; detail = $null }', + 'try {', + ' & wsl.exe @wslArgs', + ' if ($LASTEXITCODE -eq 0) {', + ' $result.ok = $true', + ' $result.detail = "WSL core installation command completed."', + ' } else {', + ' $result.detail = "wsl.exe exited with code $LASTEXITCODE."', + ' }', + '} catch {', + ' $result.detail = $_.Exception.Message', + '}', + '$result | ConvertTo-Json -Compress | Set-Content -Path $resultFile -Encoding utf8', + 'if ($result.ok) { exit 0 }', + 'exit 1', + '', + ].join('\n'); + } + + #escapePowerShellSingleQuotedString(value: string): string { + return value.replaceAll("'", "''"); + } + + #looksLikeElevationCancelled(result: ExecResult): boolean { + const combined = `${result.stdout}\n${result.stderr}`.toLowerCase(); + return ( + combined.includes('cancelled') || + combined.includes('canceled') || + combined.includes('operation was canceled') || + combined.includes('operation was cancelled') || + combined.includes('1223') + ); + } + + #firstNonEmpty(...values: string[]): string | null { + for (const value of values) { + const trimmed = value.trim(); + if (trimmed) { + return trimmed; + } + } + return null; + } + + #stripBom(value: string): string { + return value.charCodeAt(0) === 0xfeff ? value.slice(1) : value; + } +} diff --git a/src/features/tmux-installer/main/infrastructure/wsl/__tests__/TmuxWslService.test.ts b/src/features/tmux-installer/main/infrastructure/wsl/__tests__/TmuxWslService.test.ts new file mode 100644 index 00000000..f3daee50 --- /dev/null +++ b/src/features/tmux-installer/main/infrastructure/wsl/__tests__/TmuxWslService.test.ts @@ -0,0 +1,177 @@ +import { describe, expect, it } from 'vitest'; + +import { TmuxWslService } from '../TmuxWslService'; + +function createPreferenceStore(initialPreferredDistro: string | null = null): { + getPreferredDistro: () => Promise; + getPreferredDistroSync: () => string | null; + setPreferredDistro: (preferredDistroName: string) => Promise; + clearPreferredDistro: () => Promise; +} { + let preferredDistro = initialPreferredDistro; + return { + async getPreferredDistro() { + return preferredDistro; + }, + getPreferredDistroSync() { + return preferredDistro; + }, + async setPreferredDistro(nextPreferredDistroName: string) { + preferredDistro = nextPreferredDistroName; + }, + async clearPreferredDistro() { + preferredDistro = null; + }, + }; +} + +function createExecFileMock( + handlers: Record< + string, + { error?: NodeJS.ErrnoException | null; stdout?: string | Buffer; stderr?: string | Buffer } + > +): ( + command: string, + args: string[], + options: { + timeout: number; + windowsHide: boolean; + maxBuffer: number; + encoding: 'buffer'; + }, + callback: (error: Error | null, stdout: string | Buffer, stderr: string | Buffer) => void +) => void { + return (_command, args, _options, callback) => { + const key = args.join(' '); + const result = handlers[key]; + if (!result) { + const error = new Error(`Unexpected WSL command: ${key}`) as NodeJS.ErrnoException; + error.code = 'EFAIL'; + callback(error, '', ''); + return; + } + callback(result.error ?? null, result.stdout ?? '', result.stderr ?? ''); + }; +} + +describe('TmuxWslService', () => { + it('reports missing WSL when status and list commands both fail', async () => { + const service = new TmuxWslService( + createExecFileMock({ + '--status': { + error: Object.assign(new Error('wsl missing'), { code: 'EFAIL' }), + stderr: 'WSL is not installed', + }, + '--list --quiet': { + error: Object.assign(new Error('wsl missing'), { code: 'EFAIL' }), + stderr: 'WSL is not installed', + }, + }), + createPreferenceStore() as never + ); + + const result = await service.probe(); + + expect(result.status.wslInstalled).toBe(false); + expect(result.status.statusDetail).toContain('WSL'); + expect(result.preference).toBeNull(); + }); + + it('detects a bootstrapped Ubuntu distro with tmux available', async () => { + const service = new TmuxWslService( + createExecFileMock({ + '--status': { stdout: 'Default Distribution: Ubuntu\nDefault Version: 2\n' }, + '--list --quiet': { stdout: 'Ubuntu\n' }, + '--list --verbose': { stdout: '* Ubuntu Running 2\n' }, + '-d Ubuntu -- sh -lc printf ready': { stdout: 'ready' }, + '-d Ubuntu -- sh -lc . /etc/os-release >/dev/null 2>&1 && printf %s "$ID"': { + stdout: 'ubuntu', + }, + '-d Ubuntu -- sh -lc command -v tmux >/dev/null 2>&1 && { tmux -V; printf "\\n"; command -v tmux; }': + { + stdout: 'tmux 3.4\n/usr/bin/tmux\n', + }, + }), + createPreferenceStore() as never + ); + + const result = await service.probe(); + + expect(result.preference?.preferredDistroName).toBe('Ubuntu'); + expect(result.status.wslInstalled).toBe(true); + expect(result.status.distroName).toBe('Ubuntu'); + expect(result.status.distroVersion).toBe(2); + expect(result.status.distroBootstrapped).toBe(true); + expect(result.status.innerPackageManager).toBe('apt'); + expect(result.status.tmuxAvailableInsideWsl).toBe(true); + expect(result.status.tmuxVersion).toBe('tmux 3.4'); + expect(result.status.tmuxBinaryPath).toBe('/usr/bin/tmux'); + }); + + it('prefers the persisted distro over the default WSL marker', async () => { + const service = new TmuxWslService( + createExecFileMock({ + '--status': { stdout: 'Default Distribution: Debian\nDefault Version: 2\n' }, + '--list --quiet': { stdout: 'Ubuntu\nDebian\n' }, + '--list --verbose': { stdout: '* Debian Running 2\n Ubuntu Stopped 2\n' }, + '-d Ubuntu -- sh -lc printf ready': { stdout: 'ready' }, + '-d Ubuntu -- sh -lc . /etc/os-release >/dev/null 2>&1 && printf %s "$ID"': { + stdout: 'ubuntu', + }, + '-d Ubuntu -- sh -lc command -v tmux >/dev/null 2>&1 && { tmux -V; printf "\\n"; command -v tmux; }': + { + stdout: 'tmux 3.4\n/usr/bin/tmux\n', + }, + }), + createPreferenceStore('Ubuntu') as never + ); + + const result = await service.probe(); + + expect(result.preference?.preferredDistroName).toBe('Ubuntu'); + expect(result.preference?.source).toBe('persisted'); + expect(result.status.distroName).toBe('Ubuntu'); + }); + + it('clears a stale preferred distro when WSL has no installed distributions', async () => { + const preferenceStore = createPreferenceStore('Ubuntu'); + const service = new TmuxWslService( + createExecFileMock({ + '--status': { stdout: 'Default Version: 2\n' }, + '--list --quiet': { stdout: '' }, + }), + preferenceStore as never + ); + + const result = await service.probe(); + + expect(result.status.distroName).toBeNull(); + expect(preferenceStore.getPreferredDistroSync()).toBeNull(); + }); + + it('switches preference source away from persisted after clearing a stale distro', async () => { + const preferenceStore = createPreferenceStore('Ubuntu'); + const service = new TmuxWslService( + createExecFileMock({ + '--status': { stdout: 'Default Distribution: Debian\nDefault Version: 2\n' }, + '--list --quiet': { stdout: 'Debian\n' }, + '--list --verbose': { stdout: '* Debian Running 2\n' }, + '-d Debian -- sh -lc printf ready': { stdout: 'ready' }, + '-d Debian -- sh -lc . /etc/os-release >/dev/null 2>&1 && printf %s "$ID"': { + stdout: 'debian', + }, + '-d Debian -- sh -lc command -v tmux >/dev/null 2>&1 && { tmux -V; printf "\\n"; command -v tmux; }': + { + stdout: 'tmux 3.4\n/usr/bin/tmux\n', + }, + }), + preferenceStore as never + ); + + const result = await service.probe(); + + expect(result.preference?.preferredDistroName).toBe('Debian'); + expect(result.preference?.source).toBe('default'); + expect(preferenceStore.getPreferredDistroSync()).toBeNull(); + }); +}); diff --git a/src/features/tmux-installer/main/infrastructure/wsl/__tests__/WindowsElevatedStepRunner.test.ts b/src/features/tmux-installer/main/infrastructure/wsl/__tests__/WindowsElevatedStepRunner.test.ts new file mode 100644 index 00000000..72adbc1b --- /dev/null +++ b/src/features/tmux-installer/main/infrastructure/wsl/__tests__/WindowsElevatedStepRunner.test.ts @@ -0,0 +1,71 @@ +import * as fsp from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { WindowsElevatedStepRunner } from '../WindowsElevatedStepRunner'; + +describe('WindowsElevatedStepRunner', () => { + it('returns success when the elevated helper writes a result file', async () => { + const runner = new WindowsElevatedStepRunner( + async (_command, args, _options, callback) => { + const launcherScriptPath = args[4]; + const resultFilePath = path.join(path.dirname(launcherScriptPath), 'result.json'); + await fsp.writeFile( + resultFilePath, + JSON.stringify({ ok: true, detail: 'WSL core installation command completed.' }), + 'utf8' + ); + callback(null, '', ''); + }, + (prefix) => fsp.mkdtemp(path.join(tmpdir(), prefix)) + ); + + const result = await runner.runWslCoreInstall(); + + expect(result.outcome).toBe('elevated_succeeded'); + expect(result.detail).toContain('completed'); + expect(result.resultFilePath).toContain('result.json'); + }); + + it('parses a UTF-8 BOM result file from PowerShell content writes', async () => { + const runner = new WindowsElevatedStepRunner( + async (_command, args, _options, callback) => { + const launcherScriptPath = args[4]; + const resultFilePath = path.join(path.dirname(launcherScriptPath), 'result.json'); + await fsp.writeFile( + resultFilePath, + `\uFEFF${JSON.stringify({ ok: true, detail: 'WSL core installation command completed.' })}`, + 'utf8' + ); + callback(null, '', ''); + }, + (prefix) => fsp.mkdtemp(path.join(tmpdir(), prefix)) + ); + + const result = await runner.runWslCoreInstall(); + + expect(result.outcome).toBe('elevated_succeeded'); + expect(result.detail).toContain('completed'); + }); + + it('treats a missing result file plus cancel text as elevation cancellation', async () => { + const runner = new WindowsElevatedStepRunner( + (_command, _args, _options, callback) => { + callback( + Object.assign(new Error('cancelled'), { code: 1 }), + '', + 'The operation was canceled by the user.' + ); + }, + (prefix) => fsp.mkdtemp(path.join(tmpdir(), prefix)) + ); + + const result = await runner.runWslCoreInstall(); + + expect(result.outcome).toBe('elevated_cancelled'); + expect(result.detail).toContain('cancelled'); + expect(result.resultFilePath).toBeNull(); + }); +}); diff --git a/src/features/tmux-installer/preload/createTmuxInstallerBridge.ts b/src/features/tmux-installer/preload/createTmuxInstallerBridge.ts new file mode 100644 index 00000000..eff2e798 --- /dev/null +++ b/src/features/tmux-installer/preload/createTmuxInstallerBridge.ts @@ -0,0 +1,43 @@ +import { + TMUX_CANCEL_INSTALL, + TMUX_GET_INSTALLER_SNAPSHOT, + TMUX_GET_STATUS, + TMUX_INSTALL, + TMUX_INSTALLER_PROGRESS, + TMUX_INVALIDATE_STATUS, + TMUX_SUBMIT_INSTALLER_INPUT, +} from '@features/tmux-installer/contracts'; + +import type { TmuxAPI } from '@features/tmux-installer/contracts'; +import type { IpcRenderer } from 'electron'; + +interface CreateTmuxInstallerBridgeDeps { + ipcRenderer: IpcRenderer; + invokeIpcWithResult: (channel: string, ...args: unknown[]) => Promise; +} + +export function createTmuxInstallerBridge({ + ipcRenderer, + invokeIpcWithResult, +}: CreateTmuxInstallerBridgeDeps): TmuxAPI { + return { + getStatus: () => invokeIpcWithResult(TMUX_GET_STATUS), + getInstallerSnapshot: () => invokeIpcWithResult(TMUX_GET_INSTALLER_SNAPSHOT), + install: () => invokeIpcWithResult(TMUX_INSTALL), + cancelInstall: () => invokeIpcWithResult(TMUX_CANCEL_INSTALL), + submitInstallerInput: (input) => invokeIpcWithResult(TMUX_SUBMIT_INSTALLER_INPUT, input), + invalidateStatus: () => invokeIpcWithResult(TMUX_INVALIDATE_STATUS), + onProgress: (callback) => { + ipcRenderer.on( + TMUX_INSTALLER_PROGRESS, + callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void + ); + return (): void => { + ipcRenderer.removeListener( + TMUX_INSTALLER_PROGRESS, + callback as (event: Electron.IpcRendererEvent, ...args: unknown[]) => void + ); + }; + }, + }; +} diff --git a/src/features/tmux-installer/preload/index.ts b/src/features/tmux-installer/preload/index.ts new file mode 100644 index 00000000..ec118c3c --- /dev/null +++ b/src/features/tmux-installer/preload/index.ts @@ -0,0 +1 @@ +export { createTmuxInstallerBridge } from './createTmuxInstallerBridge'; diff --git a/src/features/tmux-installer/renderer/adapters/TmuxInstallerBannerAdapter.ts b/src/features/tmux-installer/renderer/adapters/TmuxInstallerBannerAdapter.ts new file mode 100644 index 00000000..7c5978bb --- /dev/null +++ b/src/features/tmux-installer/renderer/adapters/TmuxInstallerBannerAdapter.ts @@ -0,0 +1,127 @@ +import { + formatInstallButtonLabel, + formatTmuxInstallerProgress, + formatTmuxInstallerTitle, + formatTmuxLocationLabel, + formatTmuxPlatformLabel, +} from '@features/tmux-installer/renderer/utils/formatTmuxInstallerText'; + +import type { + TmuxInstallerSnapshot, + TmuxInstallHint, + TmuxStatus, +} from '@features/tmux-installer/contracts'; + +export interface TmuxInstallerBannerViewModel { + visible: boolean; + loading: boolean; + title: string; + body: string; + error: string | null; + platformLabel: string | null; + locationLabel: string | null; + runtimeReadyLabel: string | null; + versionLabel: string | null; + phase: TmuxInstallerSnapshot['phase']; + progressPercent: number | null; + logs: string[]; + manualHints: TmuxInstallHint[]; + primaryGuideUrl: string | null; + installSupported: boolean; + installDisabled: boolean; + installLabel: string; + canCancel: boolean; + acceptsInput: boolean; + inputPrompt: string | null; + inputSecret: boolean; + detailsOpen: boolean; +} + +interface AdaptInput { + status: TmuxStatus | null; + snapshot: TmuxInstallerSnapshot; + loading: boolean; + error: string | null; + detailsOpen: boolean; +} + +export class TmuxInstallerBannerAdapter { + static create(): TmuxInstallerBannerAdapter { + return new TmuxInstallerBannerAdapter(); + } + + adapt(input: AdaptInput): TmuxInstallerBannerViewModel { + const status = input.status; + const snapshot = input.snapshot; + const visible = input.loading + ? false + : (status ? !status.effective.runtimeReady : true) || snapshot.phase !== 'idle'; + const title = + snapshot.phase === 'idle' && status?.effective.available && !status.effective.runtimeReady + ? 'tmux needs one more step' + : formatTmuxInstallerTitle(snapshot.phase); + const primaryGuideUrl = + status?.autoInstall.manualHints.find((hint) => typeof hint.url === 'string')?.url ?? null; + const body = + input.error ?? + snapshot.error ?? + snapshot.detail ?? + snapshot.message ?? + status?.effective.detail ?? + status?.wsl?.statusDetail ?? + 'tmux improves persistent teammate reliability and cleaner recovery for long-running tasks.'; + const runtimeReadyLabel = status + ? status.effective.runtimeReady + ? 'Ready for persistent teammates' + : status.effective.available + ? 'Installed, but not active yet' + : null + : null; + const versionLabel = + status?.effective.version ?? status?.host.version ?? status?.wsl?.tmuxVersion ?? null; + const installLabel = + snapshot.phase === 'idle' && + status?.platform === 'win32' && + status.autoInstall.strategy === 'wsl' && + status.autoInstall.supported + ? !status.wsl?.wslInstalled + ? 'Install WSL' + : !status.wsl?.distroName + ? 'Install Ubuntu in WSL' + : 'Install tmux in WSL' + : formatInstallButtonLabel(snapshot.phase); + + return { + visible, + loading: input.loading, + title, + body, + error: input.error ?? snapshot.error ?? status?.error ?? null, + platformLabel: formatTmuxPlatformLabel(status?.platform ?? null), + locationLabel: formatTmuxLocationLabel(status?.effective.location ?? null), + runtimeReadyLabel, + versionLabel, + phase: snapshot.phase, + progressPercent: formatTmuxInstallerProgress(snapshot.phase), + logs: snapshot.logs, + manualHints: status?.autoInstall.manualHints ?? [], + primaryGuideUrl, + installSupported: status?.autoInstall.supported ?? false, + installDisabled: + input.loading || + snapshot.phase === 'preparing' || + snapshot.phase === 'checking' || + snapshot.phase === 'requesting_privileges' || + snapshot.phase === 'pending_external_elevation' || + snapshot.phase === 'waiting_for_external_step' || + snapshot.phase === 'installing' || + snapshot.phase === 'verifying', + installLabel, + canCancel: snapshot.canCancel, + acceptsInput: snapshot.acceptsInput, + inputPrompt: snapshot.inputPrompt, + inputSecret: snapshot.inputSecret, + detailsOpen: input.detailsOpen, + }; + } +} diff --git a/src/features/tmux-installer/renderer/adapters/__tests__/TmuxInstallerBannerAdapter.test.ts b/src/features/tmux-installer/renderer/adapters/__tests__/TmuxInstallerBannerAdapter.test.ts new file mode 100644 index 00000000..20b89577 --- /dev/null +++ b/src/features/tmux-installer/renderer/adapters/__tests__/TmuxInstallerBannerAdapter.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, it } from 'vitest'; + +import { TmuxInstallerBannerAdapter } from '../TmuxInstallerBannerAdapter'; + +import type { TmuxInstallerSnapshot, TmuxStatus } from '@features/tmux-installer/contracts'; + +const baseStatus: TmuxStatus = { + platform: 'darwin', + nativeSupported: true, + checkedAt: new Date().toISOString(), + host: { + available: false, + version: null, + binaryPath: null, + error: null, + }, + effective: { + available: false, + location: null, + version: null, + binaryPath: null, + runtimeReady: false, + detail: 'tmux improves persistent teammate reliability.', + }, + error: null, + autoInstall: { + supported: true, + strategy: 'homebrew', + packageManagerLabel: 'Homebrew', + requiresTerminalInput: false, + requiresAdmin: false, + requiresRestart: false, + mayOpenExternalWindow: false, + reasonIfUnsupported: null, + manualHints: [{ title: 'Homebrew', description: 'Recommended', command: 'brew install tmux' }], + }, +}; + +const idleSnapshot: TmuxInstallerSnapshot = { + phase: 'idle', + strategy: null, + message: null, + detail: null, + error: null, + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + logs: [], + updatedAt: new Date().toISOString(), +}; + +describe('TmuxInstallerBannerAdapter', () => { + it('builds an install-ready view model for unavailable tmux', () => { + const adapter = TmuxInstallerBannerAdapter.create(); + + const result = adapter.adapt({ + status: baseStatus, + snapshot: idleSnapshot, + loading: false, + error: null, + detailsOpen: false, + }); + + expect(result.visible).toBe(true); + expect(result.installSupported).toBe(true); + expect(result.installDisabled).toBe(false); + expect(result.installLabel).toBe('Install tmux'); + expect(result.platformLabel).toBe('macOS'); + expect(result.runtimeReadyLabel).toBeNull(); + expect(result.primaryGuideUrl).toBeNull(); + expect(result.progressPercent).toBeNull(); + expect(result.manualHints).toHaveLength(1); + expect(result.body).toContain('persistent teammate reliability'); + }); + + it('prioritizes renderer errors and disables the install button while installing', () => { + const adapter = TmuxInstallerBannerAdapter.create(); + + const result = adapter.adapt({ + status: baseStatus, + snapshot: { + ...idleSnapshot, + phase: 'installing', + strategy: 'homebrew', + message: 'brew install tmux', + canCancel: true, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + logs: ['Downloading bottle...'], + }, + loading: false, + error: 'Renderer bridge failed', + detailsOpen: true, + }); + + expect(result.title).toBe('Installing tmux'); + expect(result.body).toBe('Renderer bridge failed'); + expect(result.error).toBe('Renderer bridge failed'); + expect(result.installDisabled).toBe(true); + expect(result.canCancel).toBe(true); + expect(result.acceptsInput).toBe(false); + expect(result.progressPercent).toBe(68); + expect(result.logs).toEqual(['Downloading bottle...']); + }); + + it('exposes a manual guide url when auto install is unavailable', () => { + const adapter = TmuxInstallerBannerAdapter.create(); + + const result = adapter.adapt({ + status: { + ...baseStatus, + platform: 'win32', + effective: { + ...baseStatus.effective, + detail: 'WSL is installed, but tmux still needs to be installed there.', + }, + autoInstall: { + ...baseStatus.autoInstall, + supported: false, + strategy: 'wsl', + manualHints: [ + { + title: 'Microsoft WSL', + description: 'Official WSL docs', + url: 'https://learn.microsoft.com/en-us/windows/wsl/install', + }, + ], + }, + }, + snapshot: { + ...idleSnapshot, + phase: 'needs_manual_step', + strategy: 'wsl', + detail: 'WSL wizard is not wired yet.', + }, + loading: false, + error: null, + detailsOpen: false, + }); + + expect(result.platformLabel).toBe('Windows'); + expect(result.primaryGuideUrl).toBe('https://learn.microsoft.com/en-us/windows/wsl/install'); + expect(result.progressPercent).toBe(100); + }); + + it('keeps the banner visible when tmux is installed but runtime is not ready yet', () => { + const adapter = TmuxInstallerBannerAdapter.create(); + + const result = adapter.adapt({ + status: { + ...baseStatus, + platform: 'win32', + effective: { + available: true, + location: 'host', + version: 'tmux 3.4', + binaryPath: 'C:\\tmux.exe', + runtimeReady: false, + detail: 'tmux was found on Windows, but WSL-backed tmux is still preferred.', + }, + }, + snapshot: idleSnapshot, + loading: false, + error: null, + detailsOpen: false, + }); + + expect(result.visible).toBe(true); + expect(result.title).toBe('tmux needs one more step'); + expect(result.locationLabel).toBe('Host runtime'); + expect(result.runtimeReadyLabel).toBe('Installed, but not active yet'); + expect(result.versionLabel).toBe('tmux 3.4'); + }); + + it('exposes installer input metadata for interactive privilege flows', () => { + const adapter = TmuxInstallerBannerAdapter.create(); + + const result = adapter.adapt({ + status: baseStatus, + snapshot: { + ...idleSnapshot, + phase: 'requesting_privileges', + strategy: 'apt', + acceptsInput: true, + inputPrompt: 'Enter password if prompted', + inputSecret: true, + }, + loading: false, + error: null, + detailsOpen: false, + }); + + expect(result.acceptsInput).toBe(true); + expect(result.inputPrompt).toBe('Enter password if prompted'); + expect(result.inputSecret).toBe(true); + }); + + it('uses Windows-specific install labels for the WSL wizard states', () => { + const adapter = TmuxInstallerBannerAdapter.create(); + + const installWslResult = adapter.adapt({ + status: { + ...baseStatus, + platform: 'win32', + autoInstall: { + ...baseStatus.autoInstall, + supported: true, + strategy: 'wsl', + }, + wsl: { + wslInstalled: false, + rebootRequired: false, + distroName: null, + distroVersion: null, + distroBootstrapped: false, + innerPackageManager: null, + tmuxAvailableInsideWsl: false, + tmuxVersion: null, + tmuxBinaryPath: null, + statusDetail: 'WSL is not installed yet.', + }, + }, + snapshot: idleSnapshot, + loading: false, + error: null, + detailsOpen: false, + }); + const installUbuntuResult = adapter.adapt({ + status: { + ...baseStatus, + platform: 'win32', + autoInstall: { + ...baseStatus.autoInstall, + supported: true, + strategy: 'wsl', + }, + wsl: { + wslInstalled: true, + rebootRequired: false, + distroName: null, + distroVersion: null, + distroBootstrapped: false, + innerPackageManager: null, + tmuxAvailableInsideWsl: false, + tmuxVersion: null, + tmuxBinaryPath: null, + statusDetail: 'No distro yet.', + }, + }, + snapshot: idleSnapshot, + loading: false, + error: null, + detailsOpen: false, + }); + + expect(installWslResult.installLabel).toBe('Install WSL'); + expect(installUbuntuResult.installLabel).toBe('Install Ubuntu in WSL'); + }); +}); diff --git a/src/features/tmux-installer/renderer/hooks/__tests__/useTmuxInstallerBanner.test.tsx b/src/features/tmux-installer/renderer/hooks/__tests__/useTmuxInstallerBanner.test.tsx new file mode 100644 index 00000000..9f1e9c21 --- /dev/null +++ b/src/features/tmux-installer/renderer/hooks/__tests__/useTmuxInstallerBanner.test.tsx @@ -0,0 +1,237 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useTmuxInstallerBanner } from '../useTmuxInstallerBanner'; + +import type { TmuxInstallerSnapshot, TmuxStatus } from '@features/tmux-installer/contracts'; + +type HookResult = ReturnType; + +const baseStatus: TmuxStatus = { + platform: 'darwin', + nativeSupported: true, + checkedAt: new Date().toISOString(), + host: { + available: false, + version: null, + binaryPath: null, + error: null, + }, + effective: { + available: false, + location: null, + version: null, + binaryPath: null, + runtimeReady: false, + detail: 'tmux improves persistent teammate reliability.', + }, + error: null, + autoInstall: { + supported: true, + strategy: 'homebrew', + packageManagerLabel: 'Homebrew', + requiresTerminalInput: false, + requiresAdmin: false, + requiresRestart: false, + mayOpenExternalWindow: false, + reasonIfUnsupported: null, + manualHints: [], + }, +}; + +const idleSnapshot: TmuxInstallerSnapshot = { + phase: 'idle', + strategy: null, + message: null, + detail: null, + error: null, + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + logs: [], + updatedAt: new Date().toISOString(), +}; + +let capturedHook: HookResult | null = null; +let progressListener: ((event: unknown, progress: TmuxInstallerSnapshot) => void) | null = null; + +const { mockApi } = vi.hoisted(() => ({ + mockApi: { + isElectronMode: vi.fn(() => true), + tmux: { + getStatus: vi.fn<() => Promise>(), + getInstallerSnapshot: vi.fn<() => Promise>(), + install: vi.fn<() => Promise>(), + cancelInstall: vi.fn<() => Promise>(), + submitInstallerInput: vi.fn<(input: string) => Promise>(), + onProgress: + vi.fn< + (callback: (event: unknown, progress: TmuxInstallerSnapshot) => void) => () => void + >(), + }, + openExternal: vi.fn<(url: string) => Promise>(), + }, +})); + +vi.mock('@renderer/api', () => ({ + api: mockApi, + isElectronMode: mockApi.isElectronMode, +})); + +function Harness(): React.JSX.Element | null { + capturedHook = useTmuxInstallerBanner(); + return null; +} + +describe('useTmuxInstallerBanner', () => { + beforeEach(() => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + capturedHook = null; + progressListener = null; + mockApi.isElectronMode.mockReturnValue(true); + mockApi.tmux.getStatus.mockResolvedValue(baseStatus); + mockApi.tmux.getInstallerSnapshot.mockResolvedValue(idleSnapshot); + mockApi.tmux.install.mockResolvedValue(undefined); + mockApi.tmux.cancelInstall.mockResolvedValue(undefined); + mockApi.tmux.submitInstallerInput.mockResolvedValue(undefined); + mockApi.openExternal.mockResolvedValue(undefined); + mockApi.tmux.onProgress.mockImplementation((callback) => { + progressListener = callback; + return () => { + if (progressListener === callback) { + progressListener = null; + } + }; + }); + }); + + afterEach(() => { + vi.clearAllMocks(); + progressListener = null; + capturedHook = null; + document.body.innerHTML = ''; + }); + + it('loads tmux status immediately on mount', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(Harness)); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mockApi.tmux.getStatus).toHaveBeenCalledTimes(1); + expect(mockApi.tmux.getInstallerSnapshot).toHaveBeenCalledTimes(1); + expect(capturedHook?.viewModel.visible).toBe(true); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('stays idle and hidden outside Electron mode', async () => { + mockApi.isElectronMode.mockReturnValue(false); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(Harness)); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mockApi.tmux.getStatus).not.toHaveBeenCalled(); + expect(mockApi.tmux.getInstallerSnapshot).not.toHaveBeenCalled(); + expect(capturedHook?.viewModel.visible).toBe(false); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('refreshes tmux status again after error and cancelled progress events', async () => { + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(Harness)); + await Promise.resolve(); + await Promise.resolve(); + }); + + mockApi.tmux.getStatus.mockClear(); + mockApi.tmux.getInstallerSnapshot.mockClear(); + + await act(async () => { + progressListener?.(null, { + ...idleSnapshot, + phase: 'error', + error: 'tmux install failed', + }); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mockApi.tmux.getStatus).toHaveBeenCalledTimes(1); + expect(mockApi.tmux.getInstallerSnapshot).toHaveBeenCalledTimes(1); + + mockApi.tmux.getStatus.mockClear(); + mockApi.tmux.getInstallerSnapshot.mockClear(); + + await act(async () => { + progressListener?.(null, { + ...idleSnapshot, + phase: 'cancelled', + message: 'tmux installation cancelled', + }); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(mockApi.tmux.getStatus).toHaveBeenCalledTimes(1); + expect(mockApi.tmux.getInstallerSnapshot).toHaveBeenCalledTimes(1); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('stores action errors instead of letting rejected installer calls disappear', async () => { + mockApi.tmux.install.mockRejectedValueOnce(new Error('bridge failed')); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(Harness)); + await Promise.resolve(); + await Promise.resolve(); + }); + + await act(async () => { + await capturedHook?.install(); + await Promise.resolve(); + }); + + expect(capturedHook?.viewModel.error).toBe('bridge failed'); + expect(capturedHook?.viewModel.body).toBe('bridge failed'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); diff --git a/src/features/tmux-installer/renderer/hooks/useTmuxInstallerBanner.ts b/src/features/tmux-installer/renderer/hooks/useTmuxInstallerBanner.ts new file mode 100644 index 00000000..fb312fce --- /dev/null +++ b/src/features/tmux-installer/renderer/hooks/useTmuxInstallerBanner.ts @@ -0,0 +1,174 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { api, isElectronMode } from '@renderer/api'; + +import { TmuxInstallerBannerAdapter } from '../adapters/TmuxInstallerBannerAdapter'; + +import type { TmuxInstallerSnapshot, TmuxStatus } from '@features/tmux-installer/contracts'; + +const IDLE_SNAPSHOT: TmuxInstallerSnapshot = { + phase: 'idle', + strategy: null, + message: null, + detail: null, + error: null, + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + logs: [], + updatedAt: new Date(0).toISOString(), +}; + +export function useTmuxInstallerBanner(): { + viewModel: ReturnType; + install: () => Promise; + cancel: () => Promise; + submitInput: (input: string) => Promise; + refresh: () => Promise; + toggleDetails: () => void; + openExternal: (url: string) => Promise; +} { + const electronMode = isElectronMode(); + const adapter = useMemo(() => TmuxInstallerBannerAdapter.create(), []); + const [status, setStatus] = useState(null); + const [snapshot, setSnapshot] = useState(IDLE_SNAPSHOT); + const [loading, setLoading] = useState(electronMode); + const [error, setError] = useState(null); + const [detailsOpen, setDetailsOpen] = useState(false); + + const getErrorMessage = useCallback((value: unknown, fallback: string): string => { + return value instanceof Error ? value.message : fallback; + }, []); + + const refresh = useCallback(async () => { + if (!electronMode) { + setLoading(false); + return; + } + + setLoading(true); + setError(null); + try { + const [nextStatus, nextSnapshot] = await Promise.all([ + api.tmux.getStatus(), + api.tmux.getInstallerSnapshot(), + ]); + setStatus(nextStatus); + setSnapshot(nextSnapshot); + } catch (nextError) { + setError(nextError instanceof Error ? nextError.message : 'Failed to load tmux state'); + } finally { + setLoading(false); + } + }, [electronMode]); + + useEffect(() => { + if (!electronMode) { + setLoading(false); + return; + } + + void refresh(); + + return api.tmux.onProgress((_event, progress) => { + setSnapshot(progress); + if ( + progress.phase === 'completed' || + progress.phase === 'needs_manual_step' || + progress.phase === 'waiting_for_external_step' || + progress.phase === 'needs_restart' || + progress.phase === 'error' || + progress.phase === 'cancelled' + ) { + void refresh(); + } + }); + }, [electronMode, refresh]); + + const install = useCallback(async () => { + if (!electronMode) { + return; + } + + setError(null); + try { + await api.tmux.install(); + } catch (nextError) { + setError(getErrorMessage(nextError, 'Failed to start tmux installation')); + } + }, [electronMode, getErrorMessage]); + + const cancel = useCallback(async () => { + if (!electronMode) { + return; + } + + setError(null); + try { + await api.tmux.cancelInstall(); + } catch (nextError) { + setError(getErrorMessage(nextError, 'Failed to cancel tmux installation')); + } + }, [electronMode, getErrorMessage]); + + const submitInput = useCallback( + async (input: string) => { + if (!electronMode) { + return false; + } + + setError(null); + try { + await api.tmux.submitInstallerInput(input); + return true; + } catch (nextError) { + setError(getErrorMessage(nextError, 'Failed to send installer input')); + return false; + } + }, + [electronMode, getErrorMessage] + ); + + const toggleDetails = useCallback(() => { + setDetailsOpen((current) => !current); + }, []); + + const openExternal = useCallback( + async (url: string) => { + if (!electronMode) { + return; + } + + setError(null); + try { + await api.openExternal(url); + } catch (nextError) { + setError(getErrorMessage(nextError, 'Failed to open the external guide')); + } + }, + [electronMode, getErrorMessage] + ); + + const viewModel = useMemo( + () => + adapter.adapt({ + status, + snapshot, + loading, + error, + detailsOpen, + }), + [adapter, detailsOpen, error, loading, snapshot, status] + ); + + return { + viewModel: electronMode ? viewModel : { ...viewModel, visible: false }, + install, + cancel, + submitInput, + refresh, + toggleDetails, + openExternal, + }; +} diff --git a/src/features/tmux-installer/renderer/index.ts b/src/features/tmux-installer/renderer/index.ts new file mode 100644 index 00000000..0cb365bd --- /dev/null +++ b/src/features/tmux-installer/renderer/index.ts @@ -0,0 +1 @@ +export { TmuxInstallerBannerView } from './ui/TmuxInstallerBannerView'; diff --git a/src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx b/src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx new file mode 100644 index 00000000..d9fc7621 --- /dev/null +++ b/src/features/tmux-installer/renderer/ui/TmuxInstallerBannerView.tsx @@ -0,0 +1,254 @@ +import React from 'react'; + +import { AlertTriangle, ExternalLink, RefreshCw, Wrench, XCircle } from 'lucide-react'; + +import { useTmuxInstallerBanner } from '../hooks/useTmuxInstallerBanner'; + +const SourceLink = ({ + label, + url, + onOpen, +}: { + label: string; + url: string; + onOpen: (url: string) => Promise; +}): React.JSX.Element => ( + +); + +export function TmuxInstallerBannerView(): React.JSX.Element | null { + const { viewModel, install, cancel, submitInput, refresh, toggleDetails, openExternal } = + useTmuxInstallerBanner(); + const [inputValue, setInputValue] = React.useState(''); + + React.useEffect(() => { + if (!viewModel.acceptsInput) { + setInputValue(''); + } + }, [viewModel.acceptsInput]); + + if (!viewModel.visible) { + return null; + } + + return ( +
+
+
+
+ {viewModel.error ? ( + + ) : ( + + )} + {viewModel.title} +
+

+ {viewModel.body} +

+ {(viewModel.platformLabel || + viewModel.locationLabel || + viewModel.runtimeReadyLabel || + viewModel.versionLabel || + viewModel.phase !== 'idle') && ( +
+ {viewModel.platformLabel && Detected OS: {viewModel.platformLabel}} + {viewModel.locationLabel && Runtime path: {viewModel.locationLabel}} + {viewModel.runtimeReadyLabel && {viewModel.runtimeReadyLabel}} + {viewModel.versionLabel && {viewModel.versionLabel}} + {viewModel.phase !== 'idle' && Phase: {viewModel.phase}} +
+ )} +
+ +
+ {viewModel.installSupported && ( + + )} + {viewModel.canCancel && ( + + )} + {viewModel.primaryGuideUrl && ( + + )} + +
+
+ + {viewModel.progressPercent !== null && ( +
+
+ Installer progress + + {viewModel.progressPercent}% + +
+
+
+
+
+ )} + + {viewModel.acceptsInput && ( +
+
{ + event.preventDefault(); + void (async () => { + const submitted = await submitInput(inputValue); + if (submitted) { + setInputValue(''); + } + })(); + }} + > + setInputValue(event.target.value)} + placeholder={viewModel.inputPrompt ?? 'Send input to the installer'} + className="min-w-0 flex-1 rounded-md border px-3 py-2 text-sm" + style={{ + borderColor: 'var(--color-border)', + backgroundColor: 'rgba(0, 0, 0, 0.12)', + color: 'var(--color-text)', + }} + autoComplete="current-password" + /> + +
+ {viewModel.inputSecret && ( +
+ Password input is sent directly to the installer terminal and is not added to the log + output. +
+ )} +
+ )} + + {viewModel.manualHints.length > 0 && ( +
+ {viewModel.manualHints.map((hint) => ( +
+
+ {hint.title} +
+
+ {hint.description} +
+ {hint.command && ( + + {hint.command} + + )} + {hint.url && ( +
+ +
+ )} +
+ ))} +
+ )} + + {(viewModel.logs.length > 0 || viewModel.error) && ( +
+ + {viewModel.detailsOpen && ( +
+              {[viewModel.error, ...viewModel.logs].filter(Boolean).join('\n')}
+            
+ )} +
+ )} +
+ ); +} diff --git a/src/features/tmux-installer/renderer/utils/formatTmuxInstallerText.ts b/src/features/tmux-installer/renderer/utils/formatTmuxInstallerText.ts new file mode 100644 index 00000000..1fe2eb66 --- /dev/null +++ b/src/features/tmux-installer/renderer/utils/formatTmuxInstallerText.ts @@ -0,0 +1,63 @@ +import type { TmuxInstallerPhase } from '@features/tmux-installer/contracts'; +import type { TmuxPlatform } from '@features/tmux-installer/contracts'; + +export function formatTmuxInstallerTitle(phase: TmuxInstallerPhase): string { + if (phase === 'preparing' || phase === 'checking') return 'Preparing tmux installation'; + if (phase === 'pending_external_elevation') return 'Waiting for an administrator step'; + if (phase === 'waiting_for_external_step') return 'Finish the external setup step'; + if (phase === 'installing') return 'Installing tmux'; + if (phase === 'verifying') return 'Verifying tmux installation'; + if (phase === 'needs_restart') return 'Restart required before tmux setup can continue'; + if (phase === 'error') return 'tmux installation failed'; + if (phase === 'needs_manual_step') return 'tmux needs a manual step'; + if (phase === 'completed') return 'tmux installed'; + if (phase === 'cancelled') return 'tmux installation cancelled'; + return 'tmux is not installed'; +} + +export function formatInstallButtonLabel(phase: TmuxInstallerPhase): string { + if (phase === 'error') return 'Retry install'; + if (phase === 'needs_manual_step') return 'Re-check'; + if (phase === 'needs_restart') return 'Re-check after restart'; + if ( + phase === 'preparing' || + phase === 'checking' || + phase === 'pending_external_elevation' || + phase === 'waiting_for_external_step' || + phase === 'installing' || + phase === 'verifying' + ) { + return 'Installing...'; + } + return 'Install tmux'; +} + +export function formatTmuxInstallerProgress(phase: TmuxInstallerPhase): number | null { + if (phase === 'checking') return 8; + if (phase === 'preparing') return 18; + if (phase === 'requesting_privileges') return 32; + if (phase === 'pending_external_elevation') return 32; + if (phase === 'waiting_for_external_step') return 48; + if (phase === 'installing') return 68; + if (phase === 'verifying') return 90; + if (phase === 'needs_restart') return 96; + if (phase === 'completed') return 100; + if (phase === 'needs_manual_step') return 100; + if (phase === 'error') return 100; + if (phase === 'cancelled') return 0; + return null; +} + +export function formatTmuxPlatformLabel(platform: TmuxPlatform | null): string | null { + if (platform === 'darwin') return 'macOS'; + if (platform === 'linux') return 'Linux'; + if (platform === 'win32') return 'Windows'; + if (platform === 'unknown') return 'Unknown OS'; + return null; +} + +export function formatTmuxLocationLabel(location: 'host' | 'wsl' | null): string | null { + if (location === 'host') return 'Host runtime'; + if (location === 'wsl') return 'WSL runtime'; + return null; +} diff --git a/src/main/index.ts b/src/main/index.ts index 832baa3e..bddcd51b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -61,6 +61,7 @@ import { join } from 'path'; import { cleanupEditorState, setEditorMainWindow } from './ipc/editor'; import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers'; import { setReviewMainWindow } from './ipc/review'; +import { setTmuxMainWindow } from './ipc/tmux'; import { ApiKeyService, ExtensionFacadeService, @@ -102,8 +103,8 @@ import { } from './utils/safeWebContentsSend'; import { syncTelemetryFlag } from './sentry'; import { - BoardTaskActivityRecordSource, BoardTaskActivityDetailService, + BoardTaskActivityRecordSource, BoardTaskActivityService, BoardTaskExactLogDetailService, BoardTaskExactLogsService, @@ -1390,6 +1391,7 @@ function createWindow(): void { if (cliInstallerService) { cliInstallerService.setMainWindow(null); } + setTmuxMainWindow(null); if (ptyTerminalService) { ptyTerminalService.setMainWindow(null); } @@ -1423,6 +1425,7 @@ function createWindow(): void { if (cliInstallerService) { cliInstallerService.setMainWindow(mainWindow); } + setTmuxMainWindow(mainWindow); if (ptyTerminalService) { ptyTerminalService.setMainWindow(mainWindow); } diff --git a/src/main/ipc/tmux.ts b/src/main/ipc/tmux.ts index ff98ee84..0c81c82e 100644 --- a/src/main/ipc/tmux.ts +++ b/src/main/ipc/tmux.ts @@ -1,138 +1,25 @@ -import { TMUX_GET_STATUS } from '@preload/constants/ipcChannels'; -import { getErrorMessage } from '@shared/utils/errorHandling'; +import { + createTmuxInstallerFeature, + registerTmuxInstallerIpc, + removeTmuxInstallerIpc, +} from '@features/tmux-installer/main'; import { createLogger } from '@shared/utils/logger'; -import { execFile } from 'child_process'; -import type { IpcResult, TmuxPlatform, TmuxStatus } from '@shared/types'; -import type { IpcMain, IpcMainInvokeEvent } from 'electron'; +import type { BrowserWindow, IpcMain } from 'electron'; const logger = createLogger('IPC:tmux'); - -let cachedStatus: { value: TmuxStatus; at: number } | null = null; -let statusInFlight: Promise | null = null; -const STATUS_CACHE_TTL_MS = 10_000; - -function mapPlatform(platform: NodeJS.Platform): TmuxPlatform { - if (platform === 'darwin' || platform === 'linux' || platform === 'win32') { - return platform; - } - return 'unknown'; -} - -function execFileAsync( - command: string, - args: string[], - timeout: number -): Promise<{ stdout: string; stderr: string }> { - return new Promise((resolve, reject) => { - execFile(command, args, { timeout }, (error, stdout, stderr) => { - if (error) { - reject(error); - return; - } - resolve({ stdout: String(stdout), stderr: String(stderr) }); - }); - }); -} - -async function resolveBinaryPath(platform: TmuxPlatform): Promise { - const locator = platform === 'win32' ? 'where' : 'which'; - try { - const { stdout } = await execFileAsync(locator, ['tmux'], 2_000); - const firstLine = stdout - .split(/\r?\n/) - .map((line) => line.trim()) - .find(Boolean); - return firstLine ?? null; - } catch { - return null; - } -} - -async function computeTmuxStatus(): Promise { - const platform = mapPlatform(process.platform); - const nativeSupported = platform === 'darwin' || platform === 'linux'; - const checkedAt = new Date().toISOString(); - - try { - const { stdout, stderr } = await execFileAsync('tmux', ['-V'], 3_000); - const version = (stdout || stderr).trim() || null; - const binaryPath = await resolveBinaryPath(platform); - return { - available: true, - version, - binaryPath, - platform, - nativeSupported, - checkedAt, - error: null, - }; - } catch (error) { - const message = getErrorMessage(error); - const missing = - typeof error === 'object' && - error !== null && - 'code' in error && - ((error as { code?: string }).code === 'ENOENT' || - (error as { code?: string }).code === 'ENOEXEC'); - - if (missing) { - return { - available: false, - version: null, - binaryPath: null, - platform, - nativeSupported, - checkedAt, - error: null, - }; - } - - logger.warn(`tmux status check failed: ${message}`); - return { - available: false, - version: null, - binaryPath: null, - platform, - nativeSupported, - checkedAt, - error: message, - }; - } -} - -async function handleGetStatus(_event: IpcMainInvokeEvent): Promise> { - try { - if (cachedStatus && Date.now() - cachedStatus.at < STATUS_CACHE_TTL_MS) { - return { success: true, data: cachedStatus.value }; - } - - if (!statusInFlight) { - statusInFlight = computeTmuxStatus() - .then((status) => { - cachedStatus = { value: status, at: Date.now() }; - return status; - }) - .finally(() => { - statusInFlight = null; - }); - } - - const status = await statusInFlight; - return { success: true, data: status }; - } catch (error) { - const message = getErrorMessage(error); - logger.error('Error in tmux:getStatus:', message); - return { success: false, error: message }; - } -} +const tmuxInstallerFeature = createTmuxInstallerFeature(); export function registerTmuxHandlers(ipcMain: IpcMain): void { - ipcMain.handle(TMUX_GET_STATUS, handleGetStatus); + registerTmuxInstallerIpc(ipcMain, tmuxInstallerFeature); logger.info('tmux handlers registered'); } export function removeTmuxHandlers(ipcMain: IpcMain): void { - ipcMain.removeHandler(TMUX_GET_STATUS); + removeTmuxInstallerIpc(ipcMain); logger.info('tmux handlers removed'); } + +export function setTmuxMainWindow(window: BrowserWindow | null): void { + tmuxInstallerFeature.setMainWindow(window); +} diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 1dc1a7ba..9ad61e36 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1,3 +1,4 @@ +import { killTmuxPaneForCurrentPlatformSync } from '@features/tmux-installer/main'; import { ConfigManager } from '@main/services/infrastructure/ConfigManager'; import { NotificationManager } from '@main/services/infrastructure/NotificationManager'; import { getAppIconPath } from '@main/utils/appIcon'; @@ -7232,7 +7233,7 @@ export class TeamProvisioningService { continue; } try { - execFileSync('tmux', ['kill-pane', '-t', paneId], { stdio: 'ignore' }); + killTmuxPaneForCurrentPlatformSync(paneId); logger.info(`[${teamName}] Killed teammate pane ${name} (${paneId}) during stop`); } catch (error) { logger.debug( diff --git a/src/main/services/team/runtimeTeammateMode.ts b/src/main/services/team/runtimeTeammateMode.ts index 4dbed40b..648961a0 100644 --- a/src/main/services/team/runtimeTeammateMode.ts +++ b/src/main/services/team/runtimeTeammateMode.ts @@ -1,34 +1,20 @@ +import { isTmuxRuntimeReadyForCurrentPlatform } from '@features/tmux-installer/main'; import { parseCliArgs } from '@shared/utils/cliArgsParser'; -import { execFile } from 'child_process'; - -const TMUX_AVAILABILITY_CACHE_TTL_MS = 10_000; interface DesktopTeammateModeDecision { injectedTeammateMode: 'tmux' | null; forceProcessTeammates: boolean; } -let tmuxAvailabilityCache: { value: boolean; at: number } | null = null; let tmuxAvailablePromise: Promise | null = null; -function execFileAsync(command: string, args: string[], timeout: number): Promise { - return new Promise((resolve, reject) => { - execFile(command, args, { timeout }, (error) => { - if (error) { - reject(error); - return; - } - resolve(); - }); - }); -} - function getExplicitTeammateMode( rawExtraCliArgs: string | undefined ): 'auto' | 'tmux' | 'in-process' | null { const tokens = parseCliArgs(rawExtraCliArgs); for (let i = 0; i < tokens.length; i += 1) { const token = tokens[i]; + // eslint-disable-next-line security/detect-possible-timing-attacks -- parsing user-supplied CLI flags, not comparing secrets if (token === '--teammate-mode') { const next = tokens[i + 1]; if (next === 'auto' || next === 'tmux' || next === 'in-process') { @@ -49,21 +35,10 @@ function getExplicitTeammateMode( } async function isTmuxAvailable(): Promise { - if ( - tmuxAvailabilityCache && - Date.now() - tmuxAvailabilityCache.at < TMUX_AVAILABILITY_CACHE_TTL_MS - ) { - return tmuxAvailabilityCache.value; - } - if (!tmuxAvailablePromise) { - tmuxAvailablePromise = execFileAsync('tmux', ['-V'], 3_000) - .then(() => true) + tmuxAvailablePromise = isTmuxRuntimeReadyForCurrentPlatform() + .then((value) => value) .catch(() => false) - .then((value) => { - tmuxAvailabilityCache = { value, at: Date.now() }; - return value; - }) .finally(() => { tmuxAvailablePromise = null; }); @@ -90,13 +65,6 @@ export async function resolveDesktopTeammateModeDecision( }; } - if (process.platform === 'win32') { - return { - injectedTeammateMode: null, - forceProcessTeammates: false, - }; - } - if (!(await isTmuxAvailable())) { return { injectedTeammateMode: null, diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 6c25b52d..b584e5b1 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -446,9 +446,14 @@ export const CLI_INSTALLER_PROGRESS = 'cliInstaller:progress'; /** Invalidate cached CLI status (forces fresh check on next getStatus) */ export const CLI_INSTALLER_INVALIDATE_STATUS = 'cliInstaller:invalidateStatus'; - -/** Get current tmux runtime availability for dashboard diagnostics */ -export const TMUX_GET_STATUS = 'tmux:getStatus'; +export { + TMUX_CANCEL_INSTALL, + TMUX_GET_INSTALLER_SNAPSHOT, + TMUX_GET_STATUS, + TMUX_INSTALL, + TMUX_INSTALLER_PROGRESS, + TMUX_INVALIDATE_STATUS, +} from '@features/tmux-installer/contracts'; // ============================================================================= // Terminal API Channels diff --git a/src/preload/index.ts b/src/preload/index.ts index 79c12a8d..6cb0d612 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1,3 +1,4 @@ +import { createTmuxInstallerBridge } from '@features/tmux-installer/preload'; import { WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL } from '@shared/constants'; import { contextBridge, ipcRenderer, webUtils } from 'electron'; @@ -184,7 +185,6 @@ import { TERMINAL_RESIZE, TERMINAL_SPAWN, TERMINAL_WRITE, - TMUX_GET_STATUS, UPDATER_CHECK, UPDATER_DOWNLOAD, UPDATER_INSTALL, @@ -243,6 +243,7 @@ import type { ClaudeRootInfo, CliInstallationStatus, CliInstallerProgress, + CliProviderId, ConflictCheckResult, ContextInfo, CreateScheduleInput, @@ -300,7 +301,6 @@ import type { TeamTask, TeamTaskStatus, TeamUpdateConfigRequest, - TmuxStatus, ToolApprovalEvent, ToolApprovalFileContent, ToolApprovalSettings, @@ -1408,7 +1408,7 @@ const electronAPI: ElectronAPI = { getStatus: async (): Promise => { return invokeIpcWithResult(CLI_INSTALLER_GET_STATUS); }, - getProviderStatus: async (providerId: import('@shared/types').CliProviderId) => { + getProviderStatus: async (providerId: CliProviderId) => { return invokeIpcWithResult(CLI_INSTALLER_GET_PROVIDER_STATUS, providerId); }, install: async (): Promise => { @@ -1431,11 +1431,7 @@ const electronAPI: ElectronAPI = { }, }, - tmux: { - getStatus: async (): Promise => { - return invokeIpcWithResult(TMUX_GET_STATUS); - }, - }, + tmux: createTmuxInstallerBridge({ ipcRenderer, invokeIpcWithResult }), // ===== Terminal API ===== terminal: { diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index b205a4f6..9617a23c 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -1122,14 +1122,58 @@ export class HttpAPIClient implements ElectronAPI { tmux: TmuxAPI = { getStatus: async (): Promise => ({ - available: true, - version: null, - binaryPath: null, platform: 'unknown', - nativeSupported: true, + nativeSupported: false, checkedAt: new Date().toISOString(), + host: { + available: false, + version: null, + binaryPath: null, + error: null, + }, + effective: { + available: false, + location: null, + version: null, + binaryPath: null, + runtimeReady: false, + detail: 'tmux diagnostics are not available in browser mode.', + }, error: null, + autoInstall: { + supported: false, + strategy: 'manual', + packageManagerLabel: null, + requiresTerminalInput: false, + requiresAdmin: false, + requiresRestart: false, + mayOpenExternalWindow: false, + reasonIfUnsupported: 'tmux installation is only available in Electron mode.', + manualHints: [], + }, }), + getInstallerSnapshot: async () => ({ + phase: 'idle', + strategy: null, + message: null, + detail: 'tmux installer is not available in browser mode.', + error: null, + canCancel: false, + acceptsInput: false, + inputPrompt: null, + inputSecret: false, + logs: [], + updatedAt: new Date().toISOString(), + }), + install: async (): Promise => { + throw new Error('tmux installer is not available in browser mode'); + }, + cancelInstall: async (): Promise => {}, + submitInstallerInput: async (): Promise => {}, + invalidateStatus: async (): Promise => {}, + onProgress: (): (() => void) => { + return () => {}; + }, }; // --------------------------------------------------------------------------- @@ -1219,41 +1263,47 @@ export class HttpAPIClient implements ElectronAPI { }; schedules = { - list: async () => { + list: async (): Promise => { console.warn('Schedules not available in browser mode'); return [] as Schedule[]; }, - get: async () => { + get: async (_id: string): Promise => { console.warn('Schedules not available in browser mode'); return null; }, - create: async () => { + create: async (_input: CreateScheduleInput): Promise => { throw new Error('Schedules not available in browser mode'); }, - update: async () => { + update: async (_id: string, _patch: UpdateSchedulePatch): Promise => { throw new Error('Schedules not available in browser mode'); }, - delete: async () => { + delete: async (_id: string): Promise => { throw new Error('Schedules not available in browser mode'); }, - pause: async () => { + pause: async (_id: string): Promise => { throw new Error('Schedules not available in browser mode'); }, - resume: async () => { + resume: async (_id: string): Promise => { throw new Error('Schedules not available in browser mode'); }, - triggerNow: async () => { + triggerNow: async (_id: string): Promise => { throw new Error('Schedules not available in browser mode'); }, - getRuns: async () => { + getRuns: async ( + _scheduleId: string, + _opts?: { limit?: number; offset?: number } + ): Promise => { console.warn('Schedules not available in browser mode'); return [] as ScheduleRun[]; }, - getRunLogs: async () => { + getRunLogs: async ( + _scheduleId: string, + _runId: string + ): Promise<{ stdout: string; stderr: string }> => { console.warn('Schedules not available in browser mode'); return { stdout: '', stderr: '' }; }, - onScheduleChange: () => { + onScheduleChange: (): (() => void) => { return () => {}; }, }; diff --git a/src/renderer/components/dashboard/TmuxStatusBanner.tsx b/src/renderer/components/dashboard/TmuxStatusBanner.tsx index e3baa475..a36aebaa 100644 --- a/src/renderer/components/dashboard/TmuxStatusBanner.tsx +++ b/src/renderer/components/dashboard/TmuxStatusBanner.tsx @@ -1,347 +1,7 @@ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { TmuxInstallerBannerView } from '@features/tmux-installer/renderer'; -import { api, isElectronMode } from '@renderer/api'; -import { AlertTriangle, ExternalLink, RefreshCw, Wrench } from 'lucide-react'; +import type { JSX } from 'react'; -import type { TmuxPlatform, TmuxStatus } from '@shared/types'; - -const OFFICIAL_TMUX_INSTALL_URL = 'https://github.com/tmux/tmux/wiki/Installing'; -const TMUX_README_URL = 'https://github.com/tmux/tmux/blob/master/README'; -const HOMEBREW_TMUX_URL = 'https://formulae.brew.sh/formula/tmux'; -const MACPORTS_TMUX_URL = 'https://ports.macports.org/port/tmux/'; -const MICROSOFT_WSL_INSTALL_URL = 'https://learn.microsoft.com/en-us/windows/wsl/install'; - -interface SourceLink { - label: string; - url: string; -} - -interface PlatformInstallGuideStep { - kind: 'text' | 'code'; - value: string; -} - -interface PlatformInstallGuide { - platform: Exclude; - title: string; - steps: PlatformInstallGuideStep[]; - sources: SourceLink[]; -} - -type BannerState = - | { loading: true; status: null; error: null } - | { loading: false; status: TmuxStatus; error: null } - | { loading: false; status: null; error: string }; - -const INITIAL_STATE: BannerState = { loading: true, status: null, error: null }; - -const PLATFORM_INSTALL_GUIDES: readonly PlatformInstallGuide[] = [ - { - platform: 'darwin', - title: 'macOS', - steps: [ - { kind: 'text', value: 'Recommended: Homebrew' }, - { kind: 'code', value: 'brew install tmux' }, - { kind: 'text', value: 'Alternative: MacPorts' }, - { kind: 'code', value: 'sudo port install tmux' }, - ], - sources: [ - { label: 'tmux guide', url: OFFICIAL_TMUX_INSTALL_URL }, - { label: 'Homebrew', url: HOMEBREW_TMUX_URL }, - { label: 'MacPorts', url: MACPORTS_TMUX_URL }, - ], - }, - { - platform: 'linux', - title: 'Linux', - steps: [ - { kind: 'text', value: 'Use your distro package manager:' }, - { kind: 'code', value: 'sudo apt install tmux' }, - { kind: 'code', value: 'sudo dnf install tmux' }, - { kind: 'code', value: 'sudo yum install tmux' }, - { kind: 'code', value: 'sudo zypper install tmux' }, - { kind: 'code', value: 'sudo pacman -S tmux' }, - ], - sources: [{ label: 'tmux guide', url: OFFICIAL_TMUX_INSTALL_URL }], - }, - { - platform: 'win32', - title: 'Windows', - steps: [ - { - kind: 'text', - value: 'The tmux docs do not provide an official native Windows install command.', - }, - { kind: 'text', value: '1. Install WSL' }, - { kind: 'code', value: 'wsl --install' }, - { kind: 'text', value: '2. Inside Ubuntu or another distro' }, - { kind: 'code', value: 'sudo apt install tmux' }, - ], - sources: [ - { label: 'tmux README', url: TMUX_README_URL }, - { label: 'tmux guide', url: OFFICIAL_TMUX_INSTALL_URL }, - { label: 'Microsoft WSL', url: MICROSOFT_WSL_INSTALL_URL }, - ], - }, -] as const; - -const SourceLinks = ({ links }: { links: SourceLink[] }): React.JSX.Element => { - return ( -
-
- Sources -
-
- {links.map((link) => ( - - ))} -
-
- ); -}; - -function getPlatformLabel(platform: TmuxPlatform): string { - if (platform === 'darwin') return 'macOS'; - if (platform === 'linux') return 'Linux'; - if (platform === 'win32') return 'Windows'; - return 'your OS'; -} - -const PlatformInstallCard = ({ guide }: { guide: PlatformInstallGuide }): React.JSX.Element => { - return ( -
-
- {guide.title} -
-
- {guide.steps.map((step) => - step.kind === 'code' ? ( - - {step.value} - - ) : ( -
{step.value}
- ) - )} - -
-
- ); -}; - -const PlatformInstallMatrix = ({ platform }: { platform: TmuxPlatform }): React.JSX.Element => { - const guides = - platform === 'unknown' - ? PLATFORM_INSTALL_GUIDES - : PLATFORM_INSTALL_GUIDES.filter((guide) => guide.platform === platform); - const singleGuide = guides.length === 1; - - return ( -
- {singleGuide && ( -
- Detected OS: {getPlatformLabel(platform)} -
- )} -
- {guides.map((guide) => ( - - ))} -
-
- ); -}; - -function getPrimaryDetail(status: TmuxStatus): string { - if (status.platform === 'darwin') { - return 'On macOS, the simplest options are Homebrew or MacPorts.'; - } - if (status.platform === 'linux') { - return 'On Linux, install tmux with your distro package manager.'; - } - if (status.platform === 'win32') { - return 'On Windows, the clearest path is WSL, then installing tmux inside your Linux distro.'; - } - return 'Install tmux with your operating system package manager.'; -} - -export const TmuxStatusBanner = (): React.JSX.Element | null => { - const isElectron = useMemo(() => isElectronMode(), []); - const [state, setState] = useState(INITIAL_STATE); - - const loadStatus = useCallback(async () => { - return api.tmux.getStatus(); - }, []); - - const fetchStatus = useCallback(async () => { - setState( - (prev) => - ({ - loading: true, - status: prev.status, - error: null, - }) as BannerState - ); - - try { - const status = await loadStatus(); - setState({ loading: false, status, error: null }); - } catch (error) { - setState({ - loading: false, - status: null, - error: error instanceof Error ? error.message : 'Failed to check tmux status', - }); - } - }, [loadStatus]); - - useEffect(() => { - if (!isElectron) { - return; - } - - let cancelled = false; - - const loadInitialStatus = async (): Promise => { - try { - const status = await loadStatus(); - if (!cancelled) { - setState({ loading: false, status, error: null }); - } - } catch (error) { - if (!cancelled) { - setState({ - loading: false, - status: null, - error: error instanceof Error ? error.message : 'Failed to check tmux status', - }); - } - } - }; - - void loadInitialStatus(); - - return () => { - cancelled = true; - }; - }, [isElectron, loadStatus]); - - if (!isElectron) return null; - if (state.loading && !state.status) return null; - - if (state.error && !state.status) { - return ( -
-
-
- -
-
- Failed to check tmux availability -
-

- {state.error} -

-
-
- -
-
- ); - } - - if (!state.status || state.status.available) { - return null; - } - - return ( -
-
-
- -
-
- tmux is not installed -
-

- Persistent team agents are more reliable on the process/tmux path. Without tmux, the - app falls back to the heavier in-process path. {getPrimaryDetail(state.status)} -

- {state.status.error && ( -

- Last check error: {state.status.error} -

- )} -
-
- -
- - -
-
- - -
- ); +export const TmuxStatusBanner = (): JSX.Element => { + return ; }; diff --git a/src/shared/types/tmux.ts b/src/shared/types/tmux.ts index d6c26099..9a8545f0 100644 --- a/src/shared/types/tmux.ts +++ b/src/shared/types/tmux.ts @@ -1,15 +1 @@ -export type TmuxPlatform = 'darwin' | 'linux' | 'win32' | 'unknown'; - -export interface TmuxStatus { - available: boolean; - version: string | null; - binaryPath: string | null; - platform: TmuxPlatform; - nativeSupported: boolean; - checkedAt: string; - error: string | null; -} - -export interface TmuxAPI { - getStatus: () => Promise; -} +export type * from '@features/tmux-installer/contracts'; diff --git a/test/main/services/team/runtimeTeammateMode.test.ts b/test/main/services/team/runtimeTeammateMode.test.ts new file mode 100644 index 00000000..61bae905 --- /dev/null +++ b/test/main/services/team/runtimeTeammateMode.test.ts @@ -0,0 +1,52 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockIsTmuxRuntimeReadyForCurrentPlatform = vi.fn<() => Promise>(); + +vi.mock('@features/tmux-installer/main', () => ({ + isTmuxRuntimeReadyForCurrentPlatform: mockIsTmuxRuntimeReadyForCurrentPlatform, +})); + +describe('runtimeTeammateMode', () => { + beforeEach(() => { + vi.resetModules(); + vi.clearAllMocks(); + }); + + it('enables process teammates in auto mode when tmux runtime is ready', async () => { + mockIsTmuxRuntimeReadyForCurrentPlatform.mockResolvedValue(true); + const { resolveDesktopTeammateModeDecision } = + await import('@main/services/team/runtimeTeammateMode'); + + const decision = await resolveDesktopTeammateModeDecision(undefined); + + expect(decision.forceProcessTeammates).toBe(true); + expect(decision.injectedTeammateMode).toBe('tmux'); + }); + + it('keeps fallback mode when tmux runtime is not ready', async () => { + mockIsTmuxRuntimeReadyForCurrentPlatform.mockResolvedValue(false); + const { resolveDesktopTeammateModeDecision } = + await import('@main/services/team/runtimeTeammateMode'); + + const decision = await resolveDesktopTeammateModeDecision(undefined); + + expect(decision.forceProcessTeammates).toBe(false); + expect(decision.injectedTeammateMode).toBeNull(); + }); + + it('re-checks tmux readiness after the environment changes instead of keeping a stale negative cache', async () => { + mockIsTmuxRuntimeReadyForCurrentPlatform + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + const { resolveDesktopTeammateModeDecision } = + await import('@main/services/team/runtimeTeammateMode'); + + const firstDecision = await resolveDesktopTeammateModeDecision(undefined); + const secondDecision = await resolveDesktopTeammateModeDecision(undefined); + + expect(firstDecision.forceProcessTeammates).toBe(false); + expect(firstDecision.injectedTeammateMode).toBeNull(); + expect(secondDecision.forceProcessTeammates).toBe(true); + expect(secondDecision.injectedTeammateMode).toBe('tmux'); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index d1b421bc..029359fe 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,7 @@ "noEmit": true, "baseUrl": ".", "paths": { + "@features/*": ["./src/features/*"], "@main/*": ["./src/main/*"], "@renderer/*": ["./src/renderer/*"], "@preload/*": ["./src/preload/*"], diff --git a/tsconfig.node.json b/tsconfig.node.json index 65b03beb..27c96603 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -12,11 +12,20 @@ "noEmit": true, "baseUrl": ".", "paths": { + "@features/*": ["./src/features/*"], "@main/*": ["./src/main/*"], "@preload/*": ["./src/preload/*"], "@shared/*": ["./src/shared/*"] }, "types": ["node"] }, - "include": ["electron.vite.config.ts", "src/main/**/*", "src/preload/**/*"] + "include": [ + "electron.vite.config.ts", + "src/main/**/*", + "src/preload/**/*", + "src/features/**/contracts/**/*", + "src/features/**/core/**/*", + "src/features/**/main/**/*", + "src/features/**/preload/**/*" + ] } diff --git a/vitest.config.ts b/vitest.config.ts index 6d1ad570..9f5b1c94 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,7 +7,7 @@ export default defineConfig({ environment: 'happy-dom', testTimeout: 15000, setupFiles: ['./test/setup.ts'], - include: ['test/**/*.test.ts'], + include: ['test/**/*.test.ts', 'src/**/*.test.ts', 'src/**/*.test.tsx'], coverage: { provider: 'v8', reporter: ['text', 'json', 'html'], @@ -17,6 +17,7 @@ export default defineConfig({ }, resolve: { alias: { + '@features': resolve(__dirname, 'src/features'), '@shared': resolve(__dirname, 'src/shared'), '@main': resolve(__dirname, 'src/main'), '@renderer': resolve(__dirname, 'src/renderer'),