diff --git a/openspec/changes/archive/2026-07-21-wsl-default-distro-gh-fallback/.openspec.yaml b/openspec/changes/archive/2026-07-21-wsl-default-distro-gh-fallback/.openspec.yaml new file mode 100644 index 000000000..c0a816254 --- /dev/null +++ b/openspec/changes/archive/2026-07-21-wsl-default-distro-gh-fallback/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-21 diff --git a/openspec/changes/archive/2026-07-21-wsl-default-distro-gh-fallback/README.md b/openspec/changes/archive/2026-07-21-wsl-default-distro-gh-fallback/README.md new file mode 100644 index 000000000..438320a84 --- /dev/null +++ b/openspec/changes/archive/2026-07-21-wsl-default-distro-gh-fallback/README.md @@ -0,0 +1,3 @@ +# wsl-default-distro-gh-fallback + +Fallback to configured default WSL distro when host gh CLI is missing diff --git a/openspec/changes/archive/2026-07-21-wsl-default-distro-gh-fallback/design.md b/openspec/changes/archive/2026-07-21-wsl-default-distro-gh-fallback/design.md new file mode 100644 index 000000000..4f5035ae7 --- /dev/null +++ b/openspec/changes/archive/2026-07-21-wsl-default-distro-gh-fallback/design.md @@ -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. diff --git a/openspec/changes/archive/2026-07-21-wsl-default-distro-gh-fallback/proposal.md b/openspec/changes/archive/2026-07-21-wsl-default-distro-gh-fallback/proposal.md new file mode 100644 index 000000000..90078cfa1 --- /dev/null +++ b/openspec/changes/archive/2026-07-21-wsl-default-distro-gh-fallback/proposal.md @@ -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. diff --git a/openspec/changes/archive/2026-07-21-wsl-default-distro-gh-fallback/specs/wsl-default-distro-gh-fallback/spec.md b/openspec/changes/archive/2026-07-21-wsl-default-distro-gh-fallback/specs/wsl-default-distro-gh-fallback/spec.md new file mode 100644 index 000000000..2fd93170d --- /dev/null +++ b/openspec/changes/archive/2026-07-21-wsl-default-distro-gh-fallback/specs/wsl-default-distro-gh-fallback/spec.md @@ -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. diff --git a/openspec/changes/archive/2026-07-21-wsl-default-distro-gh-fallback/tasks.md b/openspec/changes/archive/2026-07-21-wsl-default-distro-gh-fallback/tasks.md new file mode 100644 index 000000000..b917e9874 --- /dev/null +++ b/openspec/changes/archive/2026-07-21-wsl-default-distro-gh-fallback/tasks.md @@ -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`. diff --git a/openspec/config.yaml b/openspec/config.yaml new file mode 100644 index 000000000..392946c67 --- /dev/null +++ b/openspec/config.yaml @@ -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 diff --git a/openspec/specs/wsl-default-distro-gh-fallback/spec.md b/openspec/specs/wsl-default-distro-gh-fallback/spec.md new file mode 100644 index 000000000..802c726d8 --- /dev/null +++ b/openspec/specs/wsl-default-distro-gh-fallback/spec.md @@ -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. + diff --git a/src/main/git/runner-wsl-gh-fallback.test.ts b/src/main/git/runner-wsl-gh-fallback.test.ts index 4cd81cda2..2bfb60e0b 100644 --- a/src/main/git/runner-wsl-gh-fallback.test.ts +++ b/src/main/git/runner-wsl-gh-fallback.test.ts @@ -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) + ) + }) }) diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index ae4e93174..716cf2052 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -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 } diff --git a/src/main/index.ts b/src/main/index.ts index 23671b2d4..d3d2a29a5 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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)