Fix WSL tool detection on Windows (#2218)
This commit is contained in:
parent
4b7c9b7213
commit
e59afeacc4
|
|
@ -1,9 +1,12 @@
|
|||
/* eslint-disable max-lines -- Why: WSL fallback, retry safety, and glab parity share mocks. */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as WslModule from '../wsl'
|
||||
|
||||
const { execFileMock, execFileSyncMock, spawnMock } = vi.hoisted(() => ({
|
||||
const { execFileMock, execFileSyncMock, spawnMock, getDefaultWslDistroMock } = vi.hoisted(() => ({
|
||||
execFileMock: vi.fn(),
|
||||
execFileSyncMock: vi.fn(),
|
||||
spawnMock: vi.fn()
|
||||
spawnMock: vi.fn(),
|
||||
getDefaultWslDistroMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('child_process', () => ({
|
||||
|
|
@ -12,6 +15,11 @@ vi.mock('child_process', () => ({
|
|||
spawn: spawnMock
|
||||
}))
|
||||
|
||||
vi.mock('../wsl', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof WslModule>()),
|
||||
getDefaultWslDistro: getDefaultWslDistroMock
|
||||
}))
|
||||
|
||||
import { ghExecFileAsync, glabExecFileAsync } from './runner'
|
||||
|
||||
describe('ghExecFileAsync WSL fallback', () => {
|
||||
|
|
@ -19,6 +27,8 @@ describe('ghExecFileAsync WSL fallback', () => {
|
|||
|
||||
beforeEach(() => {
|
||||
execFileMock.mockReset()
|
||||
getDefaultWslDistroMock.mockReset()
|
||||
getDefaultWslDistroMock.mockReturnValue(null)
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'win32'
|
||||
|
|
@ -257,6 +267,30 @@ describe('ghExecFileAsync WSL fallback', () => {
|
|||
expect(execFileMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('retries cwd-less gh calls through the default WSL distro when host gh is missing', async () => {
|
||||
getDefaultWslDistroMock.mockReturnValue('Ubuntu')
|
||||
execFileMock
|
||||
.mockImplementationOnce((_binary, _args, _options, callback) => {
|
||||
callback(Object.assign(new Error('spawn gh ENOENT'), { code: 'ENOENT' }))
|
||||
})
|
||||
.mockImplementationOnce((_binary, _args, _options, callback) => {
|
||||
callback(null, { stdout: '{"resources":{}}', stderr: '' })
|
||||
})
|
||||
|
||||
await expect(ghExecFileAsync(['api', 'rate_limit'])).resolves.toEqual({
|
||||
stdout: '{"resources":{}}',
|
||||
stderr: ''
|
||||
})
|
||||
|
||||
expect(execFileMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'wsl.exe',
|
||||
['-d', 'Ubuntu', '--', 'bash', '-c', "gh 'api' 'rate_limit'"],
|
||||
expect.objectContaining({ cwd: undefined }),
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it('does not retry non-idempotent glab transient failures', async () => {
|
||||
execFileMock.mockImplementation((_binary, _args, _options, callback) => {
|
||||
callback(
|
||||
|
|
@ -295,6 +329,30 @@ describe('ghExecFileAsync WSL fallback', () => {
|
|||
expect(execFileMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('retries cwd-less glab calls through the default WSL distro when host glab is missing', async () => {
|
||||
getDefaultWslDistroMock.mockReturnValue('Ubuntu')
|
||||
execFileMock
|
||||
.mockImplementationOnce((_binary, _args, _options, callback) => {
|
||||
callback(Object.assign(new Error('spawn glab ENOENT'), { code: 'ENOENT' }))
|
||||
})
|
||||
.mockImplementationOnce((_binary, _args, _options, callback) => {
|
||||
callback(null, { stdout: '[]', stderr: '' })
|
||||
})
|
||||
|
||||
await expect(glabExecFileAsync(['api', 'projects'])).resolves.toEqual({
|
||||
stdout: '[]',
|
||||
stderr: ''
|
||||
})
|
||||
|
||||
expect(execFileMock).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'wsl.exe',
|
||||
['-d', 'Ubuntu', '--', 'bash', '-c', "glab 'api' 'projects'"],
|
||||
expect.objectContaining({ cwd: undefined }),
|
||||
expect.any(Function)
|
||||
)
|
||||
})
|
||||
|
||||
it('still retries idempotent glab transient failures', async () => {
|
||||
execFileMock
|
||||
.mockImplementationOnce((_binary, _args, _options, callback) => {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ consistent across every repo-scoped subprocess call. */
|
|||
*/
|
||||
import { execFile, execFileSync, spawn, type ChildProcess, type SpawnOptions } from 'child_process'
|
||||
import { promisify } from 'util'
|
||||
import { parseWslPath, toWindowsWslPath, type WslPathInfo } from '../wsl'
|
||||
import { getDefaultWslDistro, parseWslPath, toWindowsWslPath, type WslPathInfo } from '../wsl'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
|
|
@ -121,6 +121,26 @@ function resolveHostGitHubCli(command: 'gh', args: string[]): ResolvedCommand {
|
|||
}
|
||||
}
|
||||
|
||||
function resolveDefaultWslCli(command: 'gh' | 'glab', args: string[]): ResolvedCommand | null {
|
||||
const distro = getDefaultWslDistro()
|
||||
return distro ? resolveCommand(command, args, undefined, distro) : null
|
||||
}
|
||||
|
||||
function isHostCommandMissing(err: unknown, command: 'gh' | 'glab'): boolean {
|
||||
if (!err || typeof err !== 'object') {
|
||||
return false
|
||||
}
|
||||
const e = err as { code?: unknown; message?: unknown; syscall?: unknown; path?: unknown }
|
||||
if (e.code === 'ENOENT') {
|
||||
return true
|
||||
}
|
||||
const message = typeof e.message === 'string' ? e.message.toLowerCase() : ''
|
||||
return (
|
||||
message.includes('enoent') &&
|
||||
(message.includes(command) || e.path === command || e.syscall === 'spawn')
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a command, its arguments, and a working directory, resolve whether
|
||||
* the invocation should be routed through wsl.exe.
|
||||
|
|
@ -502,6 +522,7 @@ export async function ghExecFileAsync(
|
|||
let resolved = resolveCommand('gh', args, options.cwd, options.wslDistro)
|
||||
let lastError: unknown
|
||||
let attemptedHostFallback = false
|
||||
let attemptedDefaultWslFallback = false
|
||||
for (let attempt = 0; attempt <= GH_RETRY_DELAYS_MS.length; attempt++) {
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(resolved.binary, resolved.args, {
|
||||
|
|
@ -515,6 +536,24 @@ export async function ghExecFileAsync(
|
|||
} catch (err) {
|
||||
lastError = err
|
||||
const { stderr } = extractExecError(err)
|
||||
if (
|
||||
process.platform === 'win32' &&
|
||||
!attemptedDefaultWslFallback &&
|
||||
resolved.wsl === null &&
|
||||
!options.cwd &&
|
||||
!options.wslDistro &&
|
||||
isHostCommandMissing(err, 'gh')
|
||||
) {
|
||||
const wslResolved = resolveDefaultWslCli('gh', args)
|
||||
if (wslResolved) {
|
||||
// Why: WSL-only Windows installs have no gh.exe on the host PATH, but
|
||||
// global calls like rate_limit/auth do not carry a repo cwd to route by.
|
||||
resolved = wslResolved
|
||||
attemptedDefaultWslFallback = true
|
||||
attempt = -1
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (!attemptedHostFallback && canFallBackToHostGitHubCli('gh', args, resolved, stderr)) {
|
||||
resolved = resolveHostGitHubCli('gh', args)
|
||||
attemptedHostFallback = true
|
||||
|
|
@ -578,8 +617,9 @@ export async function glabExecFileAsync(
|
|||
args: string[],
|
||||
options: GlabExecOptions = {}
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
const resolved = resolveCommand('glab', args, options.cwd, options.wslDistro)
|
||||
let resolved = resolveCommand('glab', args, options.cwd, options.wslDistro)
|
||||
let lastError: unknown
|
||||
let attemptedDefaultWslFallback = false
|
||||
for (let attempt = 0; attempt <= GH_RETRY_DELAYS_MS.length; attempt++) {
|
||||
try {
|
||||
const { stdout, stderr } = await execFileAsync(resolved.binary, resolved.args, {
|
||||
|
|
@ -593,6 +633,23 @@ export async function glabExecFileAsync(
|
|||
} catch (err) {
|
||||
lastError = err
|
||||
const { stderr } = extractExecError(err)
|
||||
if (
|
||||
process.platform === 'win32' &&
|
||||
!attemptedDefaultWslFallback &&
|
||||
resolved.wsl === null &&
|
||||
!options.cwd &&
|
||||
!options.wslDistro &&
|
||||
isHostCommandMissing(err, 'glab')
|
||||
) {
|
||||
const wslResolved = resolveDefaultWslCli('glab', args)
|
||||
if (wslResolved) {
|
||||
// Why: mirror gh's WSL-only fallback for global GitLab project/auth calls.
|
||||
resolved = wslResolved
|
||||
attemptedDefaultWslFallback = true
|
||||
attempt = -1
|
||||
continue
|
||||
}
|
||||
}
|
||||
const isLastAttempt = attempt >= GH_RETRY_DELAYS_MS.length
|
||||
// Why: mirror gh's write-safety gate. A transient error after GitLab
|
||||
// applies a POST/PATCH/PUT/DELETE must not create duplicate comments,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/* eslint-disable max-lines -- Why: preflight tests share expensive process/preload mocks across
|
||||
install, auth, agent detection, and refresh branches. */
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
handleMock,
|
||||
|
|
@ -65,6 +65,7 @@ import {
|
|||
type HandlerMap = Record<string, (_event?: unknown, args?: { force?: boolean }) => Promise<unknown>>
|
||||
|
||||
describe('preflight', () => {
|
||||
const originalPlatform = process.platform
|
||||
const handlers: HandlerMap = {}
|
||||
const defaultBitbucketStatus = { configured: false, authenticated: false, account: null }
|
||||
const defaultAzureDevOpsStatus = {
|
||||
|
|
@ -94,6 +95,10 @@ describe('preflight', () => {
|
|||
getAzureDevOpsAuthStatusMock.mockResolvedValue(defaultAzureDevOpsStatus)
|
||||
getGiteaAuthStatusMock.mockResolvedValue(defaultGiteaStatus)
|
||||
_resetPreflightCache()
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'darwin'
|
||||
})
|
||||
|
||||
for (const key of Object.keys(handlers)) {
|
||||
delete handlers[key]
|
||||
|
|
@ -104,6 +109,13 @@ describe('preflight', () => {
|
|||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: originalPlatform
|
||||
})
|
||||
})
|
||||
|
||||
// Why: every preflight run probes (in order) `git --version`, `gh --version`,
|
||||
// `glab --version`, then in parallel `gh auth status` + `glab auth status` —
|
||||
// five execFile calls per cycle. Tests below provide values for all five.
|
||||
|
|
@ -187,6 +199,49 @@ describe('preflight', () => {
|
|||
expect(status.glab).toEqual({ installed: true, authenticated: false })
|
||||
})
|
||||
|
||||
it('prefers the selected WSL distro when checking gh for a WSL workspace', async () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'win32'
|
||||
})
|
||||
execFileAsyncMock.mockImplementation(async (command, args) => {
|
||||
if (command === 'git') {
|
||||
return { stdout: 'git version 2.0.0\n' }
|
||||
}
|
||||
if (command === 'gh') {
|
||||
throw Object.assign(new Error('spawn gh ENOENT'), { code: 'ENOENT' })
|
||||
}
|
||||
if (command === 'glab') {
|
||||
throw Object.assign(new Error('spawn glab ENOENT'), { code: 'ENOENT' })
|
||||
}
|
||||
if (command === 'wsl.exe') {
|
||||
const script = String(args[5])
|
||||
if (script === "'gh' --version") {
|
||||
return { stdout: 'gh version 2.0.0\n' }
|
||||
}
|
||||
if (script === "'gh' auth status") {
|
||||
return { stdout: 'github.com\n - Active account: true\n' }
|
||||
}
|
||||
throw new Error(`unexpected WSL script ${script}`)
|
||||
}
|
||||
throw new Error(`unexpected command ${String(command)}`)
|
||||
})
|
||||
|
||||
const status = await runPreflightCheck(false, { wslDistro: 'Ubuntu' })
|
||||
|
||||
expect(status.gh).toEqual({ installed: true, authenticated: true })
|
||||
expect(execFileAsyncMock).toHaveBeenCalledWith(
|
||||
'wsl.exe',
|
||||
['-d', 'Ubuntu', '--', 'bash', '-lc', "'gh' --version"],
|
||||
{ encoding: 'utf-8', timeout: 5000 }
|
||||
)
|
||||
expect(execFileAsyncMock).toHaveBeenCalledWith(
|
||||
'wsl.exe',
|
||||
['-d', 'Ubuntu', '--', 'bash', '-lc', "'gh' auth status"],
|
||||
{ encoding: 'utf-8', timeout: 5000 }
|
||||
)
|
||||
})
|
||||
|
||||
it('re-runs the probe when forced so updated gh auth state is visible without relaunch', async () => {
|
||||
execFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: 'git version 2.0.0\n' })
|
||||
|
|
@ -304,6 +359,28 @@ describe('preflight', () => {
|
|||
await expect(handlers['preflight:detectAgents']()).resolves.toEqual(['cursor'])
|
||||
})
|
||||
|
||||
it('detects agents from the selected WSL distro for a WSL workspace', async () => {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'win32'
|
||||
})
|
||||
execFileAsyncMock.mockImplementation(async (command, args) => {
|
||||
if (command === 'where') {
|
||||
throw new Error('not found')
|
||||
}
|
||||
if (command !== 'wsl.exe') {
|
||||
throw new Error(`unexpected command ${String(command)}`)
|
||||
}
|
||||
const script = String(args[5])
|
||||
if (script === "command -v 'claude'") {
|
||||
return { stdout: '/home/test/.local/bin/claude\n' }
|
||||
}
|
||||
throw new Error('not found')
|
||||
})
|
||||
|
||||
await expect(detectInstalledAgents({ wslDistro: 'Ubuntu' })).resolves.toEqual(['claude'])
|
||||
})
|
||||
|
||||
it('refreshes via preflight:refreshAgents by re-hydrating PATH before re-detecting', async () => {
|
||||
// Why: the Agents settings Refresh button calls this path. It must (1) ask
|
||||
// the shell hydrator for a fresh PATH, (2) merge any new segments, then
|
||||
|
|
|
|||
|
|
@ -10,9 +10,12 @@ import { getBitbucketAuthStatus } from '../bitbucket/client'
|
|||
import { getGiteaAuthStatus } from '../gitea/client'
|
||||
import { _resetKnownHostsCache } from '../gitlab/gl-utils'
|
||||
import { getActiveMultiplexer } from './ssh'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
type PreflightRuntimeContext = {
|
||||
wslDistro?: string | null
|
||||
}
|
||||
|
||||
export type PreflightStatus = {
|
||||
git: { installed: boolean }
|
||||
gh: { installed: boolean; authenticated: boolean }
|
||||
|
|
@ -47,9 +50,25 @@ export function _resetPreflightCache(): void {
|
|||
cached = null
|
||||
}
|
||||
|
||||
async function isCommandAvailable(command: string): Promise<boolean> {
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replace(/'/g, "'\\''")}'`
|
||||
}
|
||||
|
||||
async function execCommandInWsl(
|
||||
distro: string,
|
||||
command: string
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
return execFileAsync('wsl.exe', ['-d', distro, '--', 'bash', '-lc', command], {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000
|
||||
}) as Promise<{ stdout: string; stderr: string }>
|
||||
}
|
||||
|
||||
async function isCommandAvailable(command: string, wslDistro?: string): Promise<boolean> {
|
||||
try {
|
||||
await execFileAsync(command, ['--version'])
|
||||
await (wslDistro
|
||||
? execCommandInWsl(wslDistro, `${shellQuote(command)} --version`)
|
||||
: execFileAsync(command, ['--version']))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
|
|
@ -59,10 +78,12 @@ async function isCommandAvailable(command: string): Promise<boolean> {
|
|||
// Why: `which`/`where` is faster than spawning the agent binary itself and avoids
|
||||
// triggering any agent-specific startup side-effects. This gives a reliable
|
||||
// PATH-based check without requiring `--version` support from each agent.
|
||||
async function isCommandOnPath(command: string): Promise<boolean> {
|
||||
async function isCommandOnPath(command: string, wslDistro?: string): Promise<boolean> {
|
||||
const finder = process.platform === 'win32' ? 'where' : 'which'
|
||||
try {
|
||||
const { stdout } = await execFileAsync(finder, [command], { encoding: 'utf-8' })
|
||||
const { stdout } = wslDistro
|
||||
? await execCommandInWsl(wslDistro, `command -v ${shellQuote(command)}`)
|
||||
: await execFileAsync(finder, [command], { encoding: 'utf-8' })
|
||||
return stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
|
|
@ -77,11 +98,32 @@ const KNOWN_AGENT_COMMANDS = Object.entries(TUI_AGENT_CONFIG).map(([id, config])
|
|||
cmd: config.detectCmd
|
||||
}))
|
||||
|
||||
export async function detectInstalledAgents(): Promise<string[]> {
|
||||
function getPreflightWslDistro(context?: PreflightRuntimeContext): string | null {
|
||||
const distro = context?.wslDistro?.trim()
|
||||
return process.platform === 'win32' && distro ? distro : null
|
||||
}
|
||||
|
||||
async function detectCommandRuntime(
|
||||
command: string,
|
||||
context?: PreflightRuntimeContext
|
||||
): Promise<{ installed: boolean; wslDistro?: string }> {
|
||||
const wslDistro = getPreflightWslDistro(context)
|
||||
if (wslDistro && (await isCommandAvailable(command, wslDistro))) {
|
||||
return { installed: true, wslDistro }
|
||||
}
|
||||
if (await isCommandAvailable(command)) {
|
||||
return { installed: true }
|
||||
}
|
||||
return { installed: false }
|
||||
}
|
||||
|
||||
export async function detectInstalledAgents(context?: PreflightRuntimeContext): Promise<string[]> {
|
||||
const wslDistro = getPreflightWslDistro(context)
|
||||
const checks = await Promise.all(
|
||||
KNOWN_AGENT_COMMANDS.map(async ({ id, cmd }) => ({
|
||||
id,
|
||||
installed: await isCommandOnPath(cmd)
|
||||
installed:
|
||||
(wslDistro ? await isCommandOnPath(cmd, wslDistro) : false) || (await isCommandOnPath(cmd))
|
||||
}))
|
||||
)
|
||||
return checks.filter((c) => c.installed).map((c) => c.id)
|
||||
|
|
@ -110,10 +152,12 @@ export type RefreshAgentsResult = {
|
|||
* Refresh — handles the "installed a new CLI, Orca doesn't see it yet" case
|
||||
* without requiring an app restart.
|
||||
*/
|
||||
export async function refreshShellPathAndDetectAgents(): Promise<RefreshAgentsResult> {
|
||||
export async function refreshShellPathAndDetectAgents(
|
||||
context?: PreflightRuntimeContext
|
||||
): Promise<RefreshAgentsResult> {
|
||||
const hydration = await hydrateShellPath({ force: true })
|
||||
const added = hydration.ok ? mergePathSegments(hydration.segments) : []
|
||||
const agents = await detectInstalledAgents()
|
||||
const agents = await detectInstalledAgents(context)
|
||||
return {
|
||||
agents,
|
||||
addedPathSegments: added,
|
||||
|
|
@ -134,11 +178,13 @@ export async function detectRemoteAgents(args: { connectionId: string }): Promis
|
|||
return result.agents
|
||||
}
|
||||
|
||||
async function isGhAuthenticated(): Promise<boolean> {
|
||||
async function isGhAuthenticated(wslDistro?: string): Promise<boolean> {
|
||||
try {
|
||||
await execFileAsync('gh', ['auth', 'status'], {
|
||||
encoding: 'utf-8'
|
||||
})
|
||||
await (wslDistro
|
||||
? execCommandInWsl(wslDistro, `${shellQuote('gh')} auth status`)
|
||||
: execFileAsync('gh', ['auth', 'status'], {
|
||||
encoding: 'utf-8'
|
||||
}))
|
||||
// Why: for plain-text `gh auth status`, exit 0 means gh did not detect any
|
||||
// authentication issues for the checked hosts/accounts.
|
||||
return true
|
||||
|
|
@ -155,9 +201,11 @@ async function isGhAuthenticated(): Promise<boolean> {
|
|||
|
||||
// Why: parallel to isGhAuthenticated for the glab CLI. glab writes auth
|
||||
// status to stderr in some versions and stdout in others; check both.
|
||||
async function isGlabAuthenticated(): Promise<boolean> {
|
||||
async function isGlabAuthenticated(wslDistro?: string): Promise<boolean> {
|
||||
try {
|
||||
await execFileAsync('glab', ['auth', 'status'], { encoding: 'utf-8' })
|
||||
await (wslDistro
|
||||
? execCommandInWsl(wslDistro, `${shellQuote('glab')} auth status`)
|
||||
: execFileAsync('glab', ['auth', 'status'], { encoding: 'utf-8' }))
|
||||
return true
|
||||
} catch (error) {
|
||||
const stdout = (error as { stdout?: string }).stdout ?? ''
|
||||
|
|
@ -167,8 +215,12 @@ async function isGlabAuthenticated(): Promise<boolean> {
|
|||
}
|
||||
}
|
||||
|
||||
export async function runPreflightCheck(force = false): Promise<PreflightStatus> {
|
||||
if (cached && !force) {
|
||||
export async function runPreflightCheck(
|
||||
force = false,
|
||||
context?: PreflightRuntimeContext
|
||||
): Promise<PreflightStatus> {
|
||||
const cacheable = !getPreflightWslDistro(context)
|
||||
if (cacheable && cached && !force) {
|
||||
return cached
|
||||
}
|
||||
|
||||
|
|
@ -182,46 +234,56 @@ export async function runPreflightCheck(force = false): Promise<PreflightStatus>
|
|||
_resetKnownHostsCache()
|
||||
}
|
||||
|
||||
const [gitInstalled, ghInstalled, glabInstalled] = await Promise.all([
|
||||
isCommandAvailable('git'),
|
||||
isCommandAvailable('gh'),
|
||||
isCommandAvailable('glab')
|
||||
const [gitProbe, ghProbe, glabProbe] = await Promise.all([
|
||||
detectCommandRuntime('git', context),
|
||||
detectCommandRuntime('gh', context),
|
||||
detectCommandRuntime('glab', context)
|
||||
])
|
||||
|
||||
const [ghAuthenticated, glabAuthenticated, bitbucket, azureDevOps, gitea] = await Promise.all([
|
||||
ghInstalled ? isGhAuthenticated() : Promise.resolve(false),
|
||||
glabInstalled ? isGlabAuthenticated() : Promise.resolve(false),
|
||||
ghProbe.installed ? isGhAuthenticated(ghProbe.wslDistro) : Promise.resolve(false),
|
||||
glabProbe.installed ? isGlabAuthenticated(glabProbe.wslDistro) : Promise.resolve(false),
|
||||
getBitbucketAuthStatus(),
|
||||
getAzureDevOpsAuthStatus(),
|
||||
getGiteaAuthStatus()
|
||||
])
|
||||
|
||||
cached = {
|
||||
git: { installed: gitInstalled },
|
||||
gh: { installed: ghInstalled, authenticated: ghAuthenticated },
|
||||
glab: { installed: glabInstalled, authenticated: glabAuthenticated },
|
||||
const result = {
|
||||
git: { installed: gitProbe.installed },
|
||||
gh: { installed: ghProbe.installed, authenticated: ghAuthenticated },
|
||||
glab: { installed: glabProbe.installed, authenticated: glabAuthenticated },
|
||||
bitbucket,
|
||||
azureDevOps,
|
||||
gitea
|
||||
}
|
||||
|
||||
return cached
|
||||
if (cacheable) {
|
||||
cached = result
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function registerPreflightHandlers(): void {
|
||||
ipcMain.handle(
|
||||
'preflight:check',
|
||||
async (_event, args?: { force?: boolean }): Promise<PreflightStatus> => {
|
||||
return runPreflightCheck(args?.force)
|
||||
async (
|
||||
_event,
|
||||
args?: PreflightRuntimeContext & { force?: boolean }
|
||||
): Promise<PreflightStatus> => {
|
||||
return runPreflightCheck(args?.force, args)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('preflight:detectAgents', async (): Promise<string[]> => {
|
||||
return detectInstalledAgents()
|
||||
})
|
||||
ipcMain.handle(
|
||||
'preflight:detectAgents',
|
||||
async (_event, args?: PreflightRuntimeContext): Promise<string[]> => {
|
||||
return detectInstalledAgents(args)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('preflight:refreshAgents', async (): Promise<RefreshAgentsResult> => {
|
||||
return refreshShellPathAndDetectAgents()
|
||||
ipcMain.handle('preflight:refreshAgents', async (_event, args?: PreflightRuntimeContext) => {
|
||||
return refreshShellPathAndDetectAgents(args)
|
||||
})
|
||||
|
||||
// Why: remote worktrees need agent detection on the SSH host, not the local
|
||||
|
|
|
|||
|
|
@ -86,6 +86,49 @@ export function toWindowsWslPath(linuxPath: string, distro: string): string {
|
|||
// ─── WSL home directory resolution ──────────────────────────────────
|
||||
|
||||
const wslHomeCache = new Map<string, string>()
|
||||
let wslDistroCache: string[] | null = null
|
||||
|
||||
function normalizeWslListOutput(output: string): string[] {
|
||||
// Why: wsl.exe can emit UTF-16-looking NUL bytes when inherited through
|
||||
// some Windows shells; strip them before line parsing.
|
||||
return output
|
||||
.replaceAll(String.fromCharCode(0), '')
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim().replace(/^\*\s*/, ''))
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function isUserWslDistro(distro: string): boolean {
|
||||
return !distro.toLowerCase().startsWith('docker-desktop')
|
||||
}
|
||||
|
||||
export function listWslDistros(): string[] {
|
||||
if (wslDistroCache) {
|
||||
return wslDistroCache
|
||||
}
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
wslDistroCache = []
|
||||
return wslDistroCache
|
||||
}
|
||||
|
||||
try {
|
||||
const output = execFileSync('wsl.exe', ['--list', '--quiet'], {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
timeout: 5000
|
||||
})
|
||||
wslDistroCache = normalizeWslListOutput(output).filter(isUserWslDistro)
|
||||
return wslDistroCache
|
||||
} catch {
|
||||
wslDistroCache = []
|
||||
return wslDistroCache
|
||||
}
|
||||
}
|
||||
|
||||
export function getDefaultWslDistro(): string | null {
|
||||
return listWslDistros()[0] ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the home directory for a WSL distro, returned as a Windows UNC path.
|
||||
|
|
|
|||
|
|
@ -389,9 +389,9 @@ export type RefreshAgentsResult = {
|
|||
}
|
||||
|
||||
export type PreflightApi = {
|
||||
check: (args?: { force?: boolean }) => Promise<PreflightStatus>
|
||||
detectAgents: () => Promise<string[]>
|
||||
refreshAgents: () => Promise<RefreshAgentsResult>
|
||||
check: (args?: { force?: boolean; wslDistro?: string | null }) => Promise<PreflightStatus>
|
||||
detectAgents: (args?: { wslDistro?: string | null }) => Promise<string[]>
|
||||
refreshAgents: (args?: { wslDistro?: string | null }) => Promise<RefreshAgentsResult>
|
||||
detectRemoteAgents: (args: { connectionId: string }) => Promise<string[]>
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1231,9 +1231,10 @@ const api = {
|
|||
}
|
||||
linear: { connected: boolean }
|
||||
}> => ipcRenderer.invoke('preflight:check', args),
|
||||
detectAgents: (): Promise<string[]> => ipcRenderer.invoke('preflight:detectAgents'),
|
||||
refreshAgents: (): Promise<RefreshAgentsResult> =>
|
||||
ipcRenderer.invoke('preflight:refreshAgents'),
|
||||
detectAgents: (args?: { wslDistro?: string | null }): Promise<string[]> =>
|
||||
ipcRenderer.invoke('preflight:detectAgents', args),
|
||||
refreshAgents: (args?: { wslDistro?: string | null }): Promise<RefreshAgentsResult> =>
|
||||
ipcRenderer.invoke('preflight:refreshAgents', args),
|
||||
detectRemoteAgents: (args: { connectionId: string }): Promise<string[]> =>
|
||||
ipcRenderer.invoke('preflight:detectRemoteAgents', args)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -2,6 +2,33 @@ import type { StateCreator } from 'zustand'
|
|||
import type { AppState } from '../types'
|
||||
import type { PathSource, ShellHydrationFailureReason, TuiAgent } from '../../../../shared/types'
|
||||
|
||||
type LocalPreflightContext = { wslDistro?: string | null } | undefined
|
||||
|
||||
function getWslDistroFromPath(path?: string | null): string | null {
|
||||
if (!path) {
|
||||
return null
|
||||
}
|
||||
const normalized = path.replace(/\\/g, '/')
|
||||
const match = normalized.match(/^\/\/(?:wsl\.localhost|wsl\$)\/([^/]+)(?:\/|$)/i)
|
||||
return match?.[1] ?? null
|
||||
}
|
||||
|
||||
function getLocalPreflightContext(state: AppState): LocalPreflightContext {
|
||||
const activeWorktree = state.activeWorktreeId
|
||||
? Object.values(state.worktreesByRepo)
|
||||
.flat()
|
||||
.find((worktree) => worktree.id === state.activeWorktreeId)
|
||||
: null
|
||||
const activePath =
|
||||
activeWorktree?.path ?? state.repos.find((repo) => repo.id === state.activeRepoId)?.path
|
||||
const wslDistro = getWslDistroFromPath(activePath)
|
||||
return wslDistro ? { wslDistro } : undefined
|
||||
}
|
||||
|
||||
function localPreflightContextKey(context: LocalPreflightContext): string {
|
||||
return context?.wslDistro ? `wsl:${context.wslDistro}` : 'host'
|
||||
}
|
||||
|
||||
export type DetectedAgentsSlice = {
|
||||
detectedAgentIds: TuiAgent[] | null
|
||||
isDetectingAgents: boolean
|
||||
|
|
@ -31,8 +58,9 @@ export type DetectedAgentsSlice = {
|
|||
|
||||
// Why: these are module-scoped (not in the store) so we can deduplicate
|
||||
// concurrent callers without storing a Promise in Zustand state.
|
||||
let detectPromise: Promise<TuiAgent[]> | null = null
|
||||
let refreshPromise: Promise<TuiAgent[]> | null = null
|
||||
let detectPromise: { key: string; promise: Promise<TuiAgent[]> } | null = null
|
||||
let refreshPromise: { key: string; promise: Promise<TuiAgent[]> } | null = null
|
||||
let detectedContextKey: string | null = null
|
||||
const remoteDetectPromises = new Map<string, Promise<TuiAgent[]>>()
|
||||
|
||||
export const createDetectedAgentsSlice: StateCreator<AppState, [], [], DetectedAgentsSlice> = (
|
||||
|
|
@ -46,19 +74,22 @@ export const createDetectedAgentsSlice: StateCreator<AppState, [], [], DetectedA
|
|||
pathFailureReason: null,
|
||||
|
||||
ensureDetectedAgents: () => {
|
||||
const context = getLocalPreflightContext(get())
|
||||
const contextKey = localPreflightContextKey(context)
|
||||
const existing = get().detectedAgentIds
|
||||
if (existing) {
|
||||
if (existing && detectedContextKey === contextKey) {
|
||||
return Promise.resolve(existing)
|
||||
}
|
||||
if (detectPromise) {
|
||||
return detectPromise
|
||||
if (detectPromise?.key === contextKey) {
|
||||
return detectPromise.promise
|
||||
}
|
||||
set({ isDetectingAgents: true })
|
||||
const pending = window.api.preflight
|
||||
.detectAgents()
|
||||
.detectAgents(context)
|
||||
.then((ids) => {
|
||||
const typed = ids as TuiAgent[]
|
||||
set({ detectedAgentIds: typed, isDetectingAgents: false })
|
||||
detectedContextKey = contextKey
|
||||
return typed
|
||||
})
|
||||
.catch(() => {
|
||||
|
|
@ -68,17 +99,19 @@ export const createDetectedAgentsSlice: StateCreator<AppState, [], [], DetectedA
|
|||
set({ isDetectingAgents: false })
|
||||
return [] as TuiAgent[]
|
||||
})
|
||||
detectPromise = pending
|
||||
detectPromise = { key: contextKey, promise: pending }
|
||||
return pending
|
||||
},
|
||||
|
||||
refreshDetectedAgents: () => {
|
||||
if (refreshPromise) {
|
||||
return refreshPromise
|
||||
const context = getLocalPreflightContext(get())
|
||||
const contextKey = localPreflightContextKey(context)
|
||||
if (refreshPromise?.key === contextKey) {
|
||||
return refreshPromise.promise
|
||||
}
|
||||
set({ isRefreshingAgents: true })
|
||||
const pending = window.api.preflight
|
||||
.refreshAgents()
|
||||
.refreshAgents(context)
|
||||
.then((result) => {
|
||||
const typed = result.agents as TuiAgent[]
|
||||
set({
|
||||
|
|
@ -89,7 +122,8 @@ export const createDetectedAgentsSlice: StateCreator<AppState, [], [], DetectedA
|
|||
})
|
||||
// Why: once refresh has run, treat its result as the current detection
|
||||
// snapshot so `ensureDetectedAgents` short-circuits.
|
||||
detectPromise = Promise.resolve(typed)
|
||||
detectedContextKey = contextKey
|
||||
detectPromise = { key: contextKey, promise: Promise.resolve(typed) }
|
||||
return typed
|
||||
})
|
||||
.catch(() => {
|
||||
|
|
@ -99,7 +133,7 @@ export const createDetectedAgentsSlice: StateCreator<AppState, [], [], DetectedA
|
|||
.finally(() => {
|
||||
refreshPromise = null
|
||||
})
|
||||
refreshPromise = pending
|
||||
refreshPromise = { key: contextKey, promise: pending }
|
||||
return pending
|
||||
},
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue