feat(tmux): add hybrid installer flow

This commit is contained in:
777genius 2026-04-14 20:07:57 +03:00
parent 8b53f63e97
commit ef44542f1d
65 changed files with 8608 additions and 551 deletions

View file

@ -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/<feature-name>/
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/<feature>/contracts`
- `@features/<feature>/main`
- `@features/<feature>/preload`
- `@features/<feature>/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

File diff suppressed because it is too large Load diff

View file

@ -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'),

View file

@ -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

View file

@ -321,6 +321,9 @@
"tsconfig*.json"
],
"paths": {
"@features/*": [
"./src/features/*"
],
"@main/*": [
"./src/main/*"
],

View file

@ -0,0 +1,11 @@
import type { TmuxInstallerProgress, TmuxInstallerSnapshot, TmuxStatus } from './dto';
export interface TmuxAPI {
getStatus: () => Promise<TmuxStatus>;
getInstallerSnapshot: () => Promise<TmuxInstallerSnapshot>;
install: () => Promise<void>;
cancelInstall: () => Promise<void>;
submitInstallerInput: (input: string) => Promise<void>;
invalidateStatus: () => Promise<void>;
onProgress: (callback: (event: unknown, data: TmuxInstallerProgress) => void) => () => void;
}

View file

@ -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';

View file

@ -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;

View file

@ -0,0 +1,3 @@
export type * from './api';
export * from './channels';
export type * from './dto';

View file

@ -0,0 +1,5 @@
export interface TmuxInstallerRunnerPort {
install(): Promise<void>;
cancel(): Promise<void>;
submitInput(input: string): Promise<void>;
}

View file

@ -0,0 +1,5 @@
import type { TmuxInstallerSnapshot } from '@features/tmux-installer/contracts';
export interface TmuxInstallerSnapshotPort {
getSnapshot(): TmuxInstallerSnapshot;
}

View file

@ -0,0 +1,6 @@
import type { TmuxStatus } from '@features/tmux-installer/contracts';
export interface TmuxStatusSourcePort {
getStatus(): Promise<TmuxStatus>;
invalidateStatus(): void;
}

View file

@ -0,0 +1,13 @@
import type { TmuxInstallerRunnerPort } from '../ports/TmuxInstallerRunnerPort';
export class CancelTmuxInstallUseCase {
readonly #runner: TmuxInstallerRunnerPort;
constructor(runner: TmuxInstallerRunnerPort) {
this.#runner = runner;
}
execute(): Promise<void> {
return this.#runner.cancel();
}
}

View file

@ -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();
}
}

View file

@ -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<TmuxStatus> {
return this.#statusSource.getStatus();
}
}

View file

@ -0,0 +1,13 @@
import type { TmuxInstallerRunnerPort } from '../ports/TmuxInstallerRunnerPort';
export class InstallTmuxUseCase {
readonly #runner: TmuxInstallerRunnerPort;
constructor(runner: TmuxInstallerRunnerPort) {
this.#runner = runner;
}
execute(): Promise<void> {
return this.#runner.install();
}
}

View file

@ -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<void> {
return this.#runner.submitInput(input);
}
}

View file

@ -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);
});
});

View file

@ -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');
});
});

View file

@ -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);
});
});

View file

@ -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,
};
}

View file

@ -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.',
};
}

View file

@ -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<IpcResult<TmuxStatus>> =>
withIpcResult(() => feature.getStatus())
);
ipcMain.handle(
TMUX_GET_INSTALLER_SNAPSHOT,
(_event: IpcMainInvokeEvent): IpcResult<TmuxInstallerSnapshot> =>
withSyncIpcResult(() => feature.getInstallerSnapshot())
);
ipcMain.handle(
TMUX_INSTALL,
(_event: IpcMainInvokeEvent): Promise<IpcResult<void>> => withIpcResult(() => feature.install())
);
ipcMain.handle(
TMUX_CANCEL_INSTALL,
(_event: IpcMainInvokeEvent): Promise<IpcResult<void>> =>
withIpcResult(() => feature.cancelInstall())
);
ipcMain.handle(
TMUX_SUBMIT_INSTALLER_INPUT,
(_event: IpcMainInvokeEvent, input: string): Promise<IpcResult<void>> =>
withIpcResult(() => feature.submitInstallerInput(input))
);
ipcMain.handle(
TMUX_INVALIDATE_STATUS,
(_event: IpcMainInvokeEvent): IpcResult<void> =>
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<T>(work: () => Promise<T>): Promise<IpcResult<T>> {
try {
return { success: true, data: await work() };
} catch (error) {
const message = getErrorMessage(error);
return { success: false, error: message };
}
}
function withSyncIpcResult<T>(work: () => T): IpcResult<T> {
try {
return { success: true, data: work() };
} catch (error) {
const message = getErrorMessage(error);
return { success: false, error: message };
}
}

View file

@ -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);
}
}

View file

@ -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<void> {
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<void> {
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<void> {
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<void> {
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<TmuxStatus> {
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<TmuxStatus> {
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<TmuxInstallPlan['command']>;
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<TmuxInstallPlan['command']>;
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<void> {
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<TmuxInstallPlan['command']>): 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<TmuxStatus> {
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<TmuxInstallerSnapshot, 'updatedAt' | 'logs'> &
Partial<Pick<TmuxInstallerSnapshot, 'logs'>> & { 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);
}
}

View file

@ -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> = {}): 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<typeof vi.fn> } {
return {
present: vi.fn(),
};
}
async function waitForSnapshot(
readSnapshot: () => TmuxInstallerSnapshot,
predicate: (snapshot: TmuxInstallerSnapshot) => boolean
): Promise<TmuxInstallerSnapshot> {
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');
});
});

View file

@ -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<TmuxStatus> | 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<TmuxStatus> {
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<TmuxStatus> {
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<TmuxBinaryProbe> {
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<TmuxWslStatus['innerPackageManager']>
): 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 <YourDistro> -- 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,
};
}
}

View file

@ -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);
});
});

View file

@ -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<TmuxStatus>;
getInstallerSnapshot(): TmuxInstallerSnapshot;
install(): Promise<void>;
cancelInstall(): Promise<void>;
submitInstallerInput(input: string): Promise<void>;
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<TmuxStatus> {
return this.#getStatusUseCase.execute();
}
getInstallerSnapshot(): TmuxInstallerSnapshot {
return this.#getSnapshotUseCase.execute();
}
install(): Promise<void> {
return this.#installUseCase.execute().finally(() => {
invalidateTmuxRuntimeStatusCache();
});
}
cancelInstall(): Promise<void> {
return this.#cancelUseCase.execute();
}
submitInstallerInput(input: string): Promise<void> {
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();
}

View file

@ -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<boolean> {
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<void> {
await runtimeCommandExecutor.killPane(paneId);
invalidateTmuxRuntimeStatusCache();
}
export function killTmuxPaneForCurrentPlatformSync(paneId: string): void {
runtimeCommandExecutor.killPaneSync(paneId);
invalidateTmuxRuntimeStatusCache();
}

View file

@ -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';

View file

@ -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, Readable, Readable> | null = null;
get activeChild(): ChildProcessByStdio<null, Readable, Readable> | 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;
}
}

View file

@ -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<TmuxInstallPlan> {
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);
}
}

View file

@ -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<string, string>,
});
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;
}
}

View file

@ -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<ResolveBinaryResult> {
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<ResolveBinaryResult> {
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<string | null> {
const locator = platform === 'win32' ? 'where' : 'which';
return this.#resolveBinaryWithLocator(locator, 'tmux', env);
}
async canRunNonInteractiveSudo(env: NodeJS.ProcessEnv): Promise<boolean> {
try {
await this.#execFileAsync('sudo', ['-n', 'true'], env, 2_000);
return true;
} catch {
return false;
}
}
async #resolveBinary(command: string, env: NodeJS.ProcessEnv): Promise<string | null> {
return this.#resolveBinaryWithLocator(
process.platform === 'win32' ? 'where' : 'which',
command,
env
);
}
async #resolveBinaryWithLocator(
locator: string,
command: string,
env: NodeJS.ProcessEnv
): Promise<string | null> {
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) });
});
});
}
}

View file

@ -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<ResolvedTmuxPlatform> {
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<LinuxPlatformInfo> {
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<boolean> {
try {
await fsp.access(path);
return true;
} catch {
return false;
}
}
}

View file

@ -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<ExecResult> {
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<void> {
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<string>();
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<string> {
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;
}
}

View file

@ -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,
})
);
});
});

View file

@ -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<string | null> {
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<void> {
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<void> {
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;
}
}
}

View file

@ -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<TmuxWslProbeResult> {
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<ExecWslResult> {
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<void> {
if (!preferredDistroName?.trim()) {
await this.#preferenceStore.clearPreferredDistro();
return;
}
await this.#preferenceStore.setPreferredDistro(preferredDistroName);
}
async #resolveInnerPackageManager(distro: string): Promise<TmuxInstallStrategy | null> {
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<ExecWslResult> {
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<ExecWslResult | null> {
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<string>();
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;
}
}

View file

@ -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<string>;
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<WindowsElevatedStepResult> {
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<ExecResult> {
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<PersistedElevationResult | null> {
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;
}
}

View file

@ -0,0 +1,177 @@
import { describe, expect, it } from 'vitest';
import { TmuxWslService } from '../TmuxWslService';
function createPreferenceStore(initialPreferredDistro: string | null = null): {
getPreferredDistro: () => Promise<string | null>;
getPreferredDistroSync: () => string | null;
setPreferredDistro: (preferredDistroName: string) => Promise<void>;
clearPreferredDistro: () => Promise<void>;
} {
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();
});
});

View file

@ -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();
});
});

View file

@ -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: <T>(channel: string, ...args: unknown[]) => Promise<T>;
}
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
);
};
},
};
}

View file

@ -0,0 +1 @@
export { createTmuxInstallerBridge } from './createTmuxInstallerBridge';

View file

@ -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,
};
}
}

View file

@ -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');
});
});

View file

@ -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<typeof useTmuxInstallerBanner>;
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<TmuxStatus>>(),
getInstallerSnapshot: vi.fn<() => Promise<TmuxInstallerSnapshot>>(),
install: vi.fn<() => Promise<void>>(),
cancelInstall: vi.fn<() => Promise<void>>(),
submitInstallerInput: vi.fn<(input: string) => Promise<void>>(),
onProgress:
vi.fn<
(callback: (event: unknown, progress: TmuxInstallerSnapshot) => void) => () => void
>(),
},
openExternal: vi.fn<(url: string) => Promise<void>>(),
},
}));
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();
});
});
});

View file

@ -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<TmuxInstallerBannerAdapter['adapt']>;
install: () => Promise<void>;
cancel: () => Promise<void>;
submitInput: (input: string) => Promise<boolean>;
refresh: () => Promise<void>;
toggleDetails: () => void;
openExternal: (url: string) => Promise<void>;
} {
const electronMode = isElectronMode();
const adapter = useMemo(() => TmuxInstallerBannerAdapter.create(), []);
const [status, setStatus] = useState<TmuxStatus | null>(null);
const [snapshot, setSnapshot] = useState<TmuxInstallerSnapshot>(IDLE_SNAPSHOT);
const [loading, setLoading] = useState(electronMode);
const [error, setError] = useState<string | null>(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,
};
}

View file

@ -0,0 +1 @@
export { TmuxInstallerBannerView } from './ui/TmuxInstallerBannerView';

View file

@ -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<void>;
}): React.JSX.Element => (
<button
type="button"
onClick={() => void onOpen(url)}
className="inline-flex items-center gap-1 rounded border px-2 py-1 text-[10px] transition-colors hover:bg-white/5"
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-secondary)' }}
>
{label}
<ExternalLink className="size-3" />
</button>
);
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 (
<div
className="mb-6 rounded-lg border-l-4 px-4 py-3"
style={{
borderLeftColor: viewModel.error ? '#ef4444' : '#f59e0b',
backgroundColor: 'rgba(245, 158, 11, 0.08)',
borderColor: 'rgba(245, 158, 11, 0.2)',
}}
>
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 text-sm font-medium">
{viewModel.error ? (
<AlertTriangle className="size-4 text-red-300" />
) : (
<Wrench className="size-4 text-amber-300" />
)}
<span>{viewModel.title}</span>
</div>
<p className="mt-1 text-sm" style={{ color: 'var(--color-text-secondary)' }}>
{viewModel.body}
</p>
{(viewModel.platformLabel ||
viewModel.locationLabel ||
viewModel.runtimeReadyLabel ||
viewModel.versionLabel ||
viewModel.phase !== 'idle') && (
<div
className="mt-2 flex flex-wrap items-center gap-2 text-[11px]"
style={{ color: 'var(--color-text-muted)' }}
>
{viewModel.platformLabel && <span>Detected OS: {viewModel.platformLabel}</span>}
{viewModel.locationLabel && <span>Runtime path: {viewModel.locationLabel}</span>}
{viewModel.runtimeReadyLabel && <span>{viewModel.runtimeReadyLabel}</span>}
{viewModel.versionLabel && <span>{viewModel.versionLabel}</span>}
{viewModel.phase !== 'idle' && <span>Phase: {viewModel.phase}</span>}
</div>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
{viewModel.installSupported && (
<button
type="button"
onClick={() => void install()}
disabled={viewModel.installDisabled}
className="inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors hover:bg-white/5 disabled:cursor-not-allowed disabled:opacity-60"
style={{ borderColor: 'var(--color-border)' }}
>
<Wrench className="size-4" />
{viewModel.installLabel}
</button>
)}
{viewModel.canCancel && (
<button
type="button"
onClick={() => void cancel()}
className="inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors hover:bg-white/5"
style={{ borderColor: 'var(--color-border)' }}
>
<XCircle className="size-4" />
Cancel
</button>
)}
{viewModel.primaryGuideUrl && (
<button
type="button"
onClick={() => void openExternal(viewModel.primaryGuideUrl)}
className="inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors hover:bg-white/5"
style={{ borderColor: 'var(--color-border)' }}
>
<ExternalLink className="size-4" />
Manual guide
</button>
)}
<button
type="button"
onClick={() => void refresh()}
className="inline-flex items-center gap-2 rounded-md border px-3 py-2 text-sm transition-colors hover:bg-white/5"
style={{ borderColor: 'var(--color-border)' }}
>
<RefreshCw className="size-4" />
Re-check
</button>
</div>
</div>
{viewModel.progressPercent !== null && (
<div className="mt-3">
<div className="mb-1 flex items-center justify-between text-[11px]">
<span style={{ color: 'var(--color-text-muted)' }}>Installer progress</span>
<span style={{ color: 'var(--color-text-secondary)' }}>
{viewModel.progressPercent}%
</span>
</div>
<div
className="h-2 overflow-hidden rounded-full"
style={{ backgroundColor: 'rgba(255, 255, 255, 0.08)' }}
>
<div
className="h-full rounded-full transition-all duration-300"
style={{
width: `${viewModel.progressPercent}%`,
backgroundColor: viewModel.error ? '#ef4444' : '#f59e0b',
}}
/>
</div>
</div>
)}
{viewModel.acceptsInput && (
<div className="mt-3 space-y-2">
<form
className="flex flex-col gap-2 sm:flex-row sm:items-center"
onSubmit={(event) => {
event.preventDefault();
void (async () => {
const submitted = await submitInput(inputValue);
if (submitted) {
setInputValue('');
}
})();
}}
>
<input
type={viewModel.inputSecret ? 'password' : 'text'}
value={inputValue}
onChange={(event) => 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"
/>
<button
type="submit"
className="inline-flex items-center justify-center rounded-md border px-3 py-2 text-sm transition-colors hover:bg-white/5 disabled:cursor-not-allowed disabled:opacity-60"
style={{ borderColor: 'var(--color-border)' }}
>
Send input
</button>
</form>
{viewModel.inputSecret && (
<div className="text-[11px]" style={{ color: 'var(--color-text-muted)' }}>
Password input is sent directly to the installer terminal and is not added to the log
output.
</div>
)}
</div>
)}
{viewModel.manualHints.length > 0 && (
<div className="mt-3 grid gap-2 lg:grid-cols-2">
{viewModel.manualHints.map((hint) => (
<div
key={`${hint.title}-${hint.command ?? hint.url ?? hint.description}`}
className="rounded-md border px-3 py-2"
style={{
borderColor: 'rgba(245, 158, 11, 0.18)',
backgroundColor: 'rgba(255, 255, 255, 0.02)',
}}
>
<div className="text-xs font-semibold" style={{ color: 'var(--color-text)' }}>
{hint.title}
</div>
<div className="mt-1 text-[11px]" style={{ color: 'var(--color-text-muted)' }}>
{hint.description}
</div>
{hint.command && (
<code className="mt-2 block rounded bg-black/20 px-2 py-1 font-mono text-[11px]">
{hint.command}
</code>
)}
{hint.url && (
<div className="mt-2">
<SourceLink label={hint.title} url={hint.url} onOpen={openExternal} />
</div>
)}
</div>
))}
</div>
)}
{(viewModel.logs.length > 0 || viewModel.error) && (
<div className="mt-3">
<button
type="button"
onClick={toggleDetails}
className="text-xs underline-offset-4 hover:underline"
style={{ color: 'var(--color-text-secondary)' }}
>
{viewModel.detailsOpen ? 'Hide details' : 'Show details'}
</button>
{viewModel.detailsOpen && (
<pre
className="mt-2 max-h-64 overflow-auto rounded-md border p-3 text-xs"
style={{
borderColor: 'var(--color-border)',
backgroundColor: 'rgba(0, 0, 0, 0.18)',
color: 'var(--color-text-secondary)',
}}
>
{[viewModel.error, ...viewModel.logs].filter(Boolean).join('\n')}
</pre>
)}
</div>
)}
</div>
);
}

View file

@ -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;
}

View file

@ -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);
}

View file

@ -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<TmuxStatus> | 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<string | null> {
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<TmuxStatus> {
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<IpcResult<TmuxStatus>> {
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);
}

View file

@ -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(

View file

@ -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<boolean> | null = null;
function execFileAsync(command: string, args: string[], timeout: number): Promise<void> {
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<boolean> {
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,

View file

@ -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

View file

@ -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<CliInstallationStatus> => {
return invokeIpcWithResult<CliInstallationStatus>(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<void> => {
@ -1431,11 +1431,7 @@ const electronAPI: ElectronAPI = {
},
},
tmux: {
getStatus: async (): Promise<TmuxStatus> => {
return invokeIpcWithResult<TmuxStatus>(TMUX_GET_STATUS);
},
},
tmux: createTmuxInstallerBridge({ ipcRenderer, invokeIpcWithResult }),
// ===== Terminal API =====
terminal: {

View file

@ -1122,14 +1122,58 @@ export class HttpAPIClient implements ElectronAPI {
tmux: TmuxAPI = {
getStatus: async (): Promise<TmuxStatus> => ({
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<void> => {
throw new Error('tmux installer is not available in browser mode');
},
cancelInstall: async (): Promise<void> => {},
submitInstallerInput: async (): Promise<void> => {},
invalidateStatus: async (): Promise<void> => {},
onProgress: (): (() => void) => {
return () => {};
},
};
// ---------------------------------------------------------------------------
@ -1219,41 +1263,47 @@ export class HttpAPIClient implements ElectronAPI {
};
schedules = {
list: async () => {
list: async (): Promise<Schedule[]> => {
console.warn('Schedules not available in browser mode');
return [] as Schedule[];
},
get: async () => {
get: async (_id: string): Promise<Schedule | null> => {
console.warn('Schedules not available in browser mode');
return null;
},
create: async () => {
create: async (_input: CreateScheduleInput): Promise<Schedule> => {
throw new Error('Schedules not available in browser mode');
},
update: async () => {
update: async (_id: string, _patch: UpdateSchedulePatch): Promise<Schedule> => {
throw new Error('Schedules not available in browser mode');
},
delete: async () => {
delete: async (_id: string): Promise<void> => {
throw new Error('Schedules not available in browser mode');
},
pause: async () => {
pause: async (_id: string): Promise<void> => {
throw new Error('Schedules not available in browser mode');
},
resume: async () => {
resume: async (_id: string): Promise<void> => {
throw new Error('Schedules not available in browser mode');
},
triggerNow: async () => {
triggerNow: async (_id: string): Promise<ScheduleRun> => {
throw new Error('Schedules not available in browser mode');
},
getRuns: async () => {
getRuns: async (
_scheduleId: string,
_opts?: { limit?: number; offset?: number }
): Promise<ScheduleRun[]> => {
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 () => {};
},
};

View file

@ -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<TmuxPlatform, 'unknown'>;
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 (
<div className="pt-1">
<div
className="text-[10px] uppercase tracking-wide"
style={{ color: 'var(--color-text-muted)' }}
>
Sources
</div>
<div className="mt-1 flex flex-wrap gap-1.5">
{links.map((link) => (
<button
key={link.url}
type="button"
onClick={() => void api.openExternal(link.url)}
className="inline-flex items-center gap-1 rounded border px-2 py-1 text-[10px] transition-colors hover:bg-white/5"
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-secondary)' }}
>
{link.label}
<ExternalLink className="size-3" />
</button>
))}
</div>
</div>
);
};
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 (
<div
className="rounded-md border px-3 py-2"
style={{
borderColor: 'rgba(245, 158, 11, 0.18)',
backgroundColor: 'rgba(255, 255, 255, 0.02)',
}}
>
<div className="mb-1 text-xs font-semibold" style={{ color: 'var(--color-text)' }}>
{guide.title}
</div>
<div className="space-y-1 text-[11px]" style={{ color: 'var(--color-text-muted)' }}>
{guide.steps.map((step) =>
step.kind === 'code' ? (
<code
key={`${guide.platform}-${step.value}`}
className="block rounded bg-black/20 px-2 py-1 font-mono"
>
{step.value}
</code>
) : (
<div key={`${guide.platform}-${step.value}`}>{step.value}</div>
)
)}
<SourceLinks links={guide.sources} />
</div>
</div>
);
};
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 (
<div className="mt-3">
{singleGuide && (
<div
className="mb-2 text-[10px] uppercase tracking-wide"
style={{ color: 'var(--color-text-muted)' }}
>
Detected OS: {getPlatformLabel(platform)}
</div>
)}
<div className={singleGuide ? 'max-w-xl' : 'grid gap-2 lg:grid-cols-3'}>
{guides.map((guide) => (
<PlatformInstallCard key={guide.platform} guide={guide} />
))}
</div>
</div>
);
};
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<BannerState>(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<void> => {
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 (
<div
className="mb-6 rounded-lg border-l-4 px-4 py-3"
style={{
borderColor: '#f59e0b',
backgroundColor: 'rgba(245, 158, 11, 0.06)',
}}
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 size-4 shrink-0" style={{ color: '#fbbf24' }} />
<div>
<div className="text-sm font-medium" style={{ color: '#fbbf24' }}>
Failed to check tmux availability
</div>
<p className="mt-1 text-xs" style={{ color: 'var(--color-text-muted)' }}>
{state.error}
</p>
</div>
</div>
<button
onClick={() => void fetchStatus()}
className="flex shrink-0 items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-white/5"
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-secondary)' }}
>
<RefreshCw className="size-3.5" />
Retry
</button>
</div>
</div>
);
}
if (!state.status || state.status.available) {
return null;
}
return (
<div
className="mb-6 rounded-lg border-l-4 px-4 py-3"
style={{
borderColor: '#f59e0b',
backgroundColor: 'rgba(245, 158, 11, 0.06)',
}}
>
<div className="flex items-start justify-between gap-4">
<div className="flex min-w-0 items-start gap-3">
<Wrench className="mt-0.5 size-4 shrink-0" style={{ color: '#fbbf24' }} />
<div className="min-w-0">
<div className="text-sm font-medium" style={{ color: '#fbbf24' }}>
tmux is not installed
</div>
<p
className="mt-1 text-xs leading-relaxed"
style={{ color: 'var(--color-text-muted)' }}
>
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)}
</p>
{state.status.error && (
<p className="mt-1 text-xs" style={{ color: '#fbbf24' }}>
Last check error: {state.status.error}
</p>
)}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<button
onClick={() => void fetchStatus()}
className="flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-white/5"
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-secondary)' }}
>
<RefreshCw className={`size-3.5 ${state.loading ? 'animate-spin' : ''}`} />
Re-check
</button>
<button
onClick={() => void api.openExternal(OFFICIAL_TMUX_INSTALL_URL)}
className="flex items-center gap-1.5 rounded-md border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-white/5"
style={{ borderColor: 'var(--color-border)', color: 'var(--color-text-secondary)' }}
>
<ExternalLink className="size-3.5" />
Official guide
</button>
</div>
</div>
<PlatformInstallMatrix platform={state.status.platform} />
</div>
);
export const TmuxStatusBanner = (): JSX.Element => {
return <TmuxInstallerBannerView />;
};

View file

@ -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<TmuxStatus>;
}
export type * from '@features/tmux-installer/contracts';

View file

@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mockIsTmuxRuntimeReadyForCurrentPlatform = vi.fn<() => Promise<boolean>>();
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');
});
});

View file

@ -13,6 +13,7 @@
"noEmit": true,
"baseUrl": ".",
"paths": {
"@features/*": ["./src/features/*"],
"@main/*": ["./src/main/*"],
"@renderer/*": ["./src/renderer/*"],
"@preload/*": ["./src/preload/*"],

View file

@ -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/**/*"
]
}

View file

@ -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'),