fix(wsl): route global CLI fallbacks to user-pinned terminalWindowsWslDistro (#9734)
This commit is contained in:
parent
b5ae776c31
commit
1ace87c155
|
|
@ -0,0 +1,2 @@
|
|||
schema: spec-driven
|
||||
created: 2026-07-21
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
# wsl-default-distro-gh-fallback
|
||||
|
||||
Fallback to configured default WSL distro when host gh CLI is missing
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
## Context
|
||||
|
||||
On Windows hosts, if a user only installs the GitHub CLI (`gh`) or GitLab CLI (`glab`) inside a WSL (Windows Subsystem for Linux) distro and lacks the host executable (`gh.exe` / `glab.exe`), global CLI commands fail with `ENOENT`.
|
||||
Currently, the system falls back to the first WSL distro returned by the `wsl --list` command, which might not be the distro containing the user's CLI installations or credentials. We need to allow routing these global calls through the user-pinned WSL distro configured in `Store`'s settings.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Connect the default fallback WSL distro for global cli commands to the user-pinned `terminalWindowsWslDistro` setting.
|
||||
- Ensure dynamic updates when the user changes settings.
|
||||
- Avoid introducing circular dependencies in `runner.ts` (so `runner.ts` must not directly import the settings store).
|
||||
|
||||
**Non-Goals:**
|
||||
- Automatically installing `gh` or `glab` in WSL.
|
||||
- Altering the WSL routing of commands that run inside a specific worktree / directory (those already carry correct `wslDistro` options).
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. In-Memory Setter for defaultWslDistroOverride in runner.ts
|
||||
We will expose a setter function `setDefaultWslDistroOverride` in [runner.ts](file:///C:/Github/orca/src/main/git/runner.ts) which saves the override in a local variable. `resolveDefaultWslCli` will prioritize this variable over `getDefaultWslDistro()`.
|
||||
- **Alternatives Considered**:
|
||||
- Importing `persistence` directly in `runner.ts`: Rejected because it introduces circular dependencies since `persistence` and other services import git/runner helpers.
|
||||
- Passing `settings` through options on every global call: Rejected because global CLI calls are initiated from various parts of the codebase (e.g. enterprise host status check, user discovery) and updating all call sites is highly intrusive and error-prone.
|
||||
|
||||
### 2. Main Process Initialization and Subscription in index.ts
|
||||
We will wire the initial setting application and listen to configuration updates in the main entry point [index.ts](file:///C:/Github/orca/src/main/index.ts).
|
||||
- **Alternatives Considered**:
|
||||
- Wiring it inside `persistence.ts`: Rejected as main process lifecycle is managed in `index.ts` and settings/menu syncs are standardly registered there.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Risk**: Pinned WSL distro is not running or takes time to start.
|
||||
- **Mitigation**: Standard `wsl.exe` command execution already handles launching of the distro if it's currently stopped, matching existing behavior.
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
## Why
|
||||
|
||||
On Windows hosts, if a user only installs the GitHub CLI (`gh`) or GitLab CLI (`glab`) inside a WSL (Windows Subsystem for Linux) distro and lacks the host executable (`gh.exe` / `glab.exe`), global CLI commands (which lack a repository `cwd` to derive a WSL context) fail with `ENOENT`. This is because they currently attempt host execution and fall back to the first WSL distro returned by the `wsl --list` command, which might not be the distro containing the user's CLI installations or configurations, leading to errors or missing command failures.
|
||||
|
||||
## What Changes
|
||||
|
||||
- Introduce a setter/getter mechanism to override the default WSL distro used for host command fallbacks.
|
||||
- Update `resolveDefaultWslCli` in `src/main/git/runner.ts` to prioritize the overridden default WSL distro over the first listed WSL distro.
|
||||
- Wire the initialization and updates of the overridden default WSL distro to follow `terminalWindowsWslDistro` from the global `Store` settings in `src/main/index.ts`.
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `wsl-default-distro-gh-fallback`: Pins the fallback WSL distro for global cli command executions (like `gh` / `glab`) to match the user's `terminalWindowsWslDistro` preference.
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
## Impact
|
||||
|
||||
- `src/main/git/runner.ts`: Add `setDefaultWslDistroOverride(distro: string | null)` and update `resolveDefaultWslCli`.
|
||||
- `src/main/index.ts`: Update `setDefaultWslDistroOverride` with initial setting at startup and hook it to settings changes.
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
## ADDED Requirements
|
||||
|
||||
### Requirement: Pin Fallback Distro to Preference
|
||||
The system MUST route global CLI command executions (such as rate limits and project discovery) through the user-pinned `terminalWindowsWslDistro` setting if host executables are missing and a WSL environment is available.
|
||||
|
||||
#### Scenario: Fallback executes in pinned WSL distro
|
||||
- **WHEN** the host GitHub CLI executable is missing, and the user has set `terminalWindowsWslDistro` to `Ubuntu`
|
||||
- **THEN** global gh commands SHALL fall back to and execute inside the `Ubuntu` WSL distro.
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
## 1. Core Logic
|
||||
|
||||
- [x] 1.1 In `src/main/git/runner.ts`, declare a local module-level variable `defaultWslDistroOverride` and export the setter function `setDefaultWslDistroOverride`.
|
||||
- [x] 1.2 In `src/main/git/runner.ts`, update `resolveDefaultWslCli` to prioritize `defaultWslDistroOverride` if it is not null.
|
||||
|
||||
## 2. Integration and Settings Synchronization
|
||||
|
||||
- [x] 2.1 In `src/main/index.ts`, query the initial store settings and call `setDefaultWslDistroOverride` at startup.
|
||||
- [x] 2.2 In `src/main/index.ts`, update `setDefaultWslDistroOverride` inside `store.onSettingsChanged` when `terminalWindowsWslDistro` changes.
|
||||
|
||||
## 3. Testing and Verification
|
||||
|
||||
- [x] 3.1 Write a new unit/integration test in `src/main/git/runner-wsl-gh-fallback.test.ts` to assert that fallback resolves to the overridden distro if configured, and falls back to default WSL distro otherwise.
|
||||
- [x] 3.2 Verify that all tests pass by running `pnpm test`.
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
schema: spec-driven
|
||||
|
||||
# Project context (optional)
|
||||
# This is shown to AI when creating artifacts.
|
||||
# Add your tech stack, conventions, style guides, domain knowledge, etc.
|
||||
# Example:
|
||||
# context: |
|
||||
# Tech stack: TypeScript, React, Node.js
|
||||
# We use conventional commits
|
||||
# Domain: e-commerce platform
|
||||
|
||||
# Per-artifact rules (optional)
|
||||
# Add custom rules for specific artifacts.
|
||||
# Example:
|
||||
# rules:
|
||||
# proposal:
|
||||
# - Keep proposals under 500 words
|
||||
# - Always include a "Non-goals" section
|
||||
# tasks:
|
||||
# - Break tasks into chunks of max 2 hours
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
# wsl-default-distro-gh-fallback Specification
|
||||
|
||||
## Purpose
|
||||
TBD - created by archiving change wsl-default-distro-gh-fallback. Update Purpose after archive.
|
||||
## Requirements
|
||||
### Requirement: Pin Fallback Distro to Preference
|
||||
The system MUST route global CLI command executions (such as rate limits and project discovery) through the user-pinned `terminalWindowsWslDistro` setting if host executables are missing and a WSL environment is available.
|
||||
|
||||
#### Scenario: Fallback executes in pinned WSL distro
|
||||
- **WHEN** the host GitHub CLI executable is missing, and the user has set `terminalWindowsWslDistro` to `Ubuntu`
|
||||
- **THEN** global gh commands SHALL fall back to and execute inside the `Ubuntu` WSL distro.
|
||||
|
||||
|
|
@ -20,7 +20,7 @@ vi.mock('../wsl', async (importOriginal) => ({
|
|||
getDefaultWslDistro: getDefaultWslDistroMock
|
||||
}))
|
||||
|
||||
import { ghExecFileAsync, glabExecFileAsync } from './runner'
|
||||
import { ghExecFileAsync, glabExecFileAsync, setDefaultWslDistroOverride } from './runner'
|
||||
import { _resetGhRateLimitBreaker } from './gh-rate-limit-breaker'
|
||||
|
||||
const PRIMARY_RATE_LIMIT_STDERR =
|
||||
|
|
@ -48,6 +48,7 @@ describe('ghExecFileAsync WSL fallback', () => {
|
|||
spawnMock.mockReset()
|
||||
getDefaultWslDistroMock.mockReset()
|
||||
getDefaultWslDistroMock.mockReturnValue(null)
|
||||
setDefaultWslDistroOverride(null)
|
||||
_resetGhRateLimitBreaker()
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
|
|
@ -624,4 +625,64 @@ describe('ghExecFileAsync WSL fallback', () => {
|
|||
|
||||
expect(execFileMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('resolves fallback to the overridden distro if configured, and falls back to default WSL distro otherwise', async () => {
|
||||
// 1) Test with override configured (should use 'Debian' override)
|
||||
setDefaultWslDistroOverride('Debian')
|
||||
getDefaultWslDistroMock.mockReturnValue('Ubuntu')
|
||||
|
||||
execFileMock
|
||||
.mockImplementationOnce((_binary, _args, _options, callback) => {
|
||||
callback(Object.assign(new Error('spawn gh ENOENT'), { code: 'ENOENT' }))
|
||||
})
|
||||
.mockImplementationOnce((binary, args, _options, callback) => {
|
||||
if (binary === 'wsl.exe' && args.includes('Debian')) {
|
||||
callback(null, { stdout: 'Logged in to github.com as override', stderr: '' })
|
||||
return
|
||||
}
|
||||
callback(new Error('Wrong distro fallback'))
|
||||
})
|
||||
|
||||
await expect(
|
||||
ghExecFileAsync(['auth', 'status'])
|
||||
).resolves.toEqual({ stdout: 'Logged in to github.com as override', stderr: '' })
|
||||
|
||||
expect(execFileMock).toHaveBeenCalledTimes(2)
|
||||
expect(execFileMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'wsl.exe',
|
||||
['-d', 'Debian', '--', 'bash', '-c', "'gh' 'auth' 'status'"],
|
||||
expect.any(Object),
|
||||
expect.any(Function)
|
||||
)
|
||||
|
||||
// 2) Test without override (should use default 'Ubuntu')
|
||||
execFileMock.mockClear()
|
||||
setDefaultWslDistroOverride(null)
|
||||
|
||||
execFileMock
|
||||
.mockImplementationOnce((_binary, _args, _options, callback) => {
|
||||
callback(Object.assign(new Error('spawn gh ENOENT'), { code: 'ENOENT' }))
|
||||
})
|
||||
.mockImplementationOnce((binary, args, _options, callback) => {
|
||||
if (binary === 'wsl.exe' && args.includes('Ubuntu')) {
|
||||
callback(null, { stdout: 'Logged in to github.com as default', stderr: '' })
|
||||
return
|
||||
}
|
||||
callback(new Error('Wrong distro fallback'))
|
||||
})
|
||||
|
||||
await expect(
|
||||
ghExecFileAsync(['auth', 'status'])
|
||||
).resolves.toEqual({ stdout: 'Logged in to github.com as default', stderr: '' })
|
||||
|
||||
expect(execFileMock).toHaveBeenCalledTimes(2)
|
||||
expect(execFileMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'wsl.exe',
|
||||
['-d', 'Ubuntu', '--', 'bash', '-c', "'gh' 'auth' 'status'"],
|
||||
expect.any(Object),
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -164,8 +164,15 @@ function resolveHostGitHubCli(command: 'gh', args: string[]): ResolvedCommand {
|
|||
}
|
||||
}
|
||||
|
||||
let defaultWslDistroOverride: string | null = null
|
||||
|
||||
// Why: allow host commands fallback to route through the user's pinned WSL distro when host execution fails.
|
||||
export function setDefaultWslDistroOverride(distro: string | null): void {
|
||||
defaultWslDistroOverride = distro
|
||||
}
|
||||
|
||||
function resolveDefaultWslCli(command: 'gh' | 'glab', args: string[]): ResolvedCommand | null {
|
||||
const distro = getDefaultWslDistro()
|
||||
const distro = defaultWslDistroOverride ?? getDefaultWslDistro()
|
||||
return distro ? resolveCommand(command, args, undefined, distro) : null
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -187,6 +187,7 @@ import { maybeAutoRenameBranchOnFirstWork } from './agent-hooks/first-work-branc
|
|||
import { rememberBranchRenameFailureOutput } from './agent-hooks/branch-rename-failure-output'
|
||||
import { renameWorktreeFolderOnFirstWork } from './agent-hooks/first-work-folder-rename'
|
||||
import { moveWorktree } from './git/worktree'
|
||||
import { setDefaultWslDistroOverride } from './git/runner'
|
||||
import { getRepoIdFromWorktreeId } from '../shared/worktree-id'
|
||||
import { parseWorkspaceKey } from '../shared/workspace-scope'
|
||||
import { setMigrationUnsupportedPtyListener } from './agent-hooks/migration-unsupported-pty-state'
|
||||
|
|
@ -1829,7 +1830,13 @@ app.whenReady().then(async () => {
|
|||
const activeOrcaProfile = ensureActiveOrcaProfile()
|
||||
store = new Store({ dataFile: activeOrcaProfile.dataFile })
|
||||
logStartupMilestone('store-loaded')
|
||||
// Why: apply initial fallback WSL distro from store settings for global git/CLI calls.
|
||||
setDefaultWslDistroOverride(store.getSettings().terminalWindowsWslDistro ?? null)
|
||||
store.onSettingsChanged((updates, settings) => {
|
||||
if ('terminalWindowsWslDistro' in updates) {
|
||||
// Why: synchronize fallback WSL distro updates to runner.
|
||||
setDefaultWslDistroOverride(settings.terminalWindowsWslDistro ?? null)
|
||||
}
|
||||
if ('showMenuBarIcon' in updates) {
|
||||
// Why: Store is the mutation authority for all settings writes, so every macOS toggle updates the native item live.
|
||||
syncMacMenuBarIcon(settings.showMenuBarIcon !== false)
|
||||
|
|
|
|||
Loading…
Reference in New Issue