feat(tmux): add hybrid installer flow
This commit is contained in:
parent
8b53f63e97
commit
ef44542f1d
65 changed files with 8608 additions and 551 deletions
297
docs/FEATURE_ARCHITECTURE_STANDARD.md
Normal file
297
docs/FEATURE_ARCHITECTURE_STANDARD.md
Normal 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
|
||||
2443
docs/research/tmux-hybrid-installer-plan.md
Normal file
2443
docs/research/tmux-hybrid-installer-plan.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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'),
|
||||
|
|
|
|||
179
eslint.config.js
179
eslint.config.js
|
|
@ -97,6 +97,54 @@ export default defineConfig([
|
|||
},
|
||||
},
|
||||
|
||||
// Import plugin configuration - Feature main/preload slices
|
||||
{
|
||||
name: 'import-plugin-features-node',
|
||||
files: ['src/features/**/main/**/*.ts', 'src/features/**/preload/**/*.ts'],
|
||||
plugins: {
|
||||
import: importPlugin,
|
||||
},
|
||||
settings: {
|
||||
'import/resolver': {
|
||||
typescript: {
|
||||
alwaysTryTypes: true,
|
||||
project: ['./tsconfig.node.json', './tsconfig.json'],
|
||||
},
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'import/no-cycle': ['error', { maxDepth: 3, ignoreExternal: true }],
|
||||
'import/no-unresolved': 'error',
|
||||
'import/no-default-export': 'warn',
|
||||
},
|
||||
},
|
||||
|
||||
// Import plugin configuration - Feature contracts/core/renderer slices
|
||||
{
|
||||
name: 'import-plugin-features-web',
|
||||
files: [
|
||||
'src/features/**/contracts/**/*.ts',
|
||||
'src/features/**/core/**/*.ts',
|
||||
'src/features/**/renderer/**/*.{ts,tsx}',
|
||||
],
|
||||
plugins: {
|
||||
import: importPlugin,
|
||||
},
|
||||
settings: {
|
||||
'import/resolver': {
|
||||
typescript: {
|
||||
alwaysTryTypes: true,
|
||||
project: ['./tsconfig.json', './tsconfig.node.json'],
|
||||
},
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'import/no-cycle': ['error', { maxDepth: 3, ignoreExternal: true }],
|
||||
'import/no-unresolved': 'error',
|
||||
'import/no-default-export': 'warn',
|
||||
},
|
||||
},
|
||||
|
||||
// Module boundaries - Enforce Electron three-process architecture
|
||||
{
|
||||
name: 'module-boundaries',
|
||||
|
|
@ -624,6 +672,137 @@ export default defineConfig([
|
|||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'feature-public-entrypoints-only',
|
||||
files: [
|
||||
'src/main/**/*.{ts,tsx}',
|
||||
'src/preload/**/*.{ts,tsx}',
|
||||
'src/renderer/**/*.{ts,tsx}',
|
||||
'src/shared/**/*.{ts,tsx}',
|
||||
],
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: [
|
||||
'@features/*/contracts/*',
|
||||
'@features/*/core/**',
|
||||
'@features/*/main/*',
|
||||
'@features/*/preload/*',
|
||||
'@features/*/renderer/*',
|
||||
],
|
||||
message: 'Import feature public entrypoints only.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'feature-core-domain-guards',
|
||||
files: ['src/features/*/core/domain/**/*.ts'],
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
paths: [
|
||||
{ name: 'electron', message: 'core/domain must stay Electron-free.' },
|
||||
{ name: 'fastify', message: 'core/domain must stay transport-free.' },
|
||||
{ name: 'child_process', message: 'core/domain must stay side-effect free.' },
|
||||
{ name: 'node:child_process', message: 'core/domain must stay side-effect free.' },
|
||||
],
|
||||
patterns: [
|
||||
{
|
||||
group: ['@main/*', '@preload/*', '@renderer/*'],
|
||||
message: 'core/domain must stay process-agnostic.',
|
||||
},
|
||||
{
|
||||
group: ['@features/*/main/**', '@features/*/preload/**', '@features/*/renderer/**'],
|
||||
message: 'core/domain must not import runtime or transport layers.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'feature-core-application-guards',
|
||||
files: ['src/features/*/core/application/**/*.ts'],
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
paths: [
|
||||
{ name: 'electron', message: 'core/application must stay Electron-free.' },
|
||||
{ name: 'fastify', message: 'core/application must stay transport-free.' },
|
||||
{ name: 'child_process', message: 'core/application must not spawn processes directly.' },
|
||||
{
|
||||
name: 'node:child_process',
|
||||
message: 'core/application must not spawn processes directly.',
|
||||
},
|
||||
],
|
||||
patterns: [
|
||||
{
|
||||
group: ['@main/*', '@preload/*', '@renderer/*'],
|
||||
message: 'core/application must stay framework-agnostic.',
|
||||
},
|
||||
{
|
||||
group: ['@features/*/main/**', '@features/*/preload/**', '@features/*/renderer/**'],
|
||||
message: 'core/application must depend on ports, not runtime adapters.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'feature-preload-guards',
|
||||
files: ['src/features/*/preload/**/*.ts'],
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
patterns: [
|
||||
{
|
||||
group: ['@main/*'],
|
||||
message: 'Feature preload should not import app-shell main modules.',
|
||||
},
|
||||
{
|
||||
group: ['@features/*/main/**'],
|
||||
message: 'Feature preload must not reach into feature main internals.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
name: 'feature-renderer-ui-guards',
|
||||
files: ['src/features/*/renderer/ui/**/*.{ts,tsx}'],
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
paths: [
|
||||
{ name: '@renderer/api', message: 'renderer/ui must stay presentational.' },
|
||||
{ name: '@renderer/store', message: 'renderer/ui must stay store-free.' },
|
||||
{ name: 'electron', message: 'renderer/ui must stay Electron-free.' },
|
||||
],
|
||||
patterns: [
|
||||
{ group: ['@main/*'], message: 'renderer/ui must not import main modules.' },
|
||||
{ group: ['@renderer/store/*'], message: 'renderer/ui must stay store-free.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
|
||||
// === IMPORTANT: eslint-config-prettier MUST be LAST ===
|
||||
// This disables all ESLint rules that conflict with Prettier
|
||||
// Prettier handles formatting, ESLint handles code quality
|
||||
|
|
|
|||
|
|
@ -321,6 +321,9 @@
|
|||
"tsconfig*.json"
|
||||
],
|
||||
"paths": {
|
||||
"@features/*": [
|
||||
"./src/features/*"
|
||||
],
|
||||
"@main/*": [
|
||||
"./src/main/*"
|
||||
],
|
||||
|
|
|
|||
11
src/features/tmux-installer/contracts/api.ts
Normal file
11
src/features/tmux-installer/contracts/api.ts
Normal 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;
|
||||
}
|
||||
7
src/features/tmux-installer/contracts/channels.ts
Normal file
7
src/features/tmux-installer/contracts/channels.ts
Normal 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';
|
||||
109
src/features/tmux-installer/contracts/dto.ts
Normal file
109
src/features/tmux-installer/contracts/dto.ts
Normal 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;
|
||||
3
src/features/tmux-installer/contracts/index.ts
Normal file
3
src/features/tmux-installer/contracts/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export type * from './api';
|
||||
export * from './channels';
|
||||
export type * from './dto';
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export interface TmuxInstallerRunnerPort {
|
||||
install(): Promise<void>;
|
||||
cancel(): Promise<void>;
|
||||
submitInput(input: string): Promise<void>;
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
import type { TmuxInstallerSnapshot } from '@features/tmux-installer/contracts';
|
||||
|
||||
export interface TmuxInstallerSnapshotPort {
|
||||
getSnapshot(): TmuxInstallerSnapshot;
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import type { TmuxStatus } from '@features/tmux-installer/contracts';
|
||||
|
||||
export interface TmuxStatusSourcePort {
|
||||
getStatus(): Promise<TmuxStatus>;
|
||||
invalidateStatus(): void;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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.',
|
||||
};
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
12
src/features/tmux-installer/main/index.ts
Normal file
12
src/features/tmux-installer/main/index.ts
Normal 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';
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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) });
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
);
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
1
src/features/tmux-installer/preload/index.ts
Normal file
1
src/features/tmux-installer/preload/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { createTmuxInstallerBridge } from './createTmuxInstallerBridge';
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
1
src/features/tmux-installer/renderer/index.ts
Normal file
1
src/features/tmux-installer/renderer/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { TmuxInstallerBannerView } from './ui/TmuxInstallerBannerView';
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 () => {};
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 />;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
52
test/main/services/team/runtimeTeammateMode.test.ts
Normal file
52
test/main/services/team/runtimeTeammateMode.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -13,6 +13,7 @@
|
|||
"noEmit": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@features/*": ["./src/features/*"],
|
||||
"@main/*": ["./src/main/*"],
|
||||
"@renderer/*": ["./src/renderer/*"],
|
||||
"@preload/*": ["./src/preload/*"],
|
||||
|
|
|
|||
|
|
@ -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/**/*"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
Loading…
Reference in a new issue