Files
orca/src/main/wsl.ts
T
NeilandOrca 46646d7ff1 chore(lint): upgrade oxlint to 1.71 + enable 7 new rules (autofixed backlog) (#6841)
* chore(lint): upgrade oxlint to 1.71 and enable 7 new rules

Upgrade oxlint 1.67.0 -> 1.71.0 (1.72 was blocked by the repo's 3-day
minimum-release-age supply-chain guard; nothing here needs it). The
bump is a no-op on the existing config.

Enable 3 error rules (backlog autofixed to zero in this commit) and
4 warn rules (surface signal without gating CI):

error (autofixed, behavior-preserving):
- unicorn/prefer-node-protocol        (~1531 sites: bare builtin -> node:)
- typescript/no-import-type-side-effects (~36: all-inline-type -> import type)
- unicorn/no-array-reverse            (19: copy-then-reverse -> toReversed)

warn (real signal, current fires are test-only/correct):
- unicorn/no-array-fill-with-reference-type  (aliasing footgun guard)
- typescript/no-unsafe-function-type         (bans bare Function type)
- unicorn/prefer-array-flat-map              (map().flat() -> flatMap())
- unicorn/prefer-regexp-test                 (.match() in bool ctx -> .test())

mobile/.oxlintrc.json extends root, so it inherits all 7; the autofix
ran from root and covered mobile/ too.

Verification (all green): oxlint 0 errors (root+mobile+aux configs),
oxfmt clean, typecheck (node+cli+web), vitest 22795 passed / 0 failed,
builds (electron-vite + web + cli) succeed. node: rewrites confirmed to
skip embedded SSH/CLI string payloads (AST-only); all toReversed sites
verified to operate on fresh copies or write-once locals.

* chore(lint): bump mobile oxlint to 1.71 so inherited rules parse

mobile/ is a standalone pnpm project pinning its own oxlint@1.67, which
lacks unicorn/no-array-fill-with-reference-type (needs >=1.70). Since
mobile/.oxlintrc.json extends the root config, mobile CI's 'cd mobile &&
oxlint' failed to parse the new rule. Bump mobile to match root (1.71).

Verified in mobile/: oxlint 0 errors, oxfmt --check clean, tsc --noEmit
pass, vitest 978 passed / 0 failed.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Orca <help@stably.ai>
2026-06-29 22:38:29 -07:00

304 lines
8.7 KiB
TypeScript

import { execFile, execFileSync } from 'node:child_process'
import { parseWslUncPath } from '../shared/wsl-paths'
export type WslPathInfo = {
distro: string
linuxPath: string
}
/**
* Detect if a Windows path is a WSL UNC path and extract the distro name
* and equivalent Linux path.
*
* Why: Windows exposes WSL filesystems as UNC paths under \\wsl.localhost\<Distro>\...
* (modern) or \\wsl$\<Distro>\... (legacy). When a repo lives on a WSL filesystem,
* native Windows git.exe is either absent or painfully slow — all process spawning
* must be routed through `wsl.exe -d <distro>` with Linux-native paths instead.
*/
export function parseWslPath(windowsPath: string): WslPathInfo | null {
if (process.platform !== 'win32') {
return null
}
return parseWslUncPath(windowsPath)
}
export function isWslPath(path: string): boolean {
return parseWslPath(path) !== null
}
/**
* Check whether a WSL UNC working directory exists by testing it inside the
* distro itself, returning null when the answer can't be determined.
*
* Why: Win32 fs.statSync against the WSL 9P filesystem (\\wsl.localhost\...)
* is unreliable for repos that live on the WSL side — it can report ENOENT for
* directories that exist, which made opening a WSL worktree fail with
* "Working directory ... does not exist". `wsl.exe -d <distro> test -d` asks
* the distro directly, which is the authoritative answer. Returns null (rather
* than false) when wsl.exe is unavailable or errors so callers can fall back to
* the fs check instead of falsely rejecting a valid directory.
*/
export function wslUncDirectoryExists(uncPath: string): boolean | null {
if (process.platform !== 'win32') {
return null
}
const info = parseWslUncPath(uncPath)
if (!info) {
return null
}
try {
execFileSync('wsl.exe', ['-d', info.distro, '--', 'test', '-d', info.linuxPath], {
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 5000
})
return true
} catch (error) {
// A non-zero exit (directory missing) surfaces as an error with a numeric
// `status`; treat that as a definitive "does not exist". Any other failure
// (wsl.exe missing, distro not running, timeout) is inconclusive -> null.
if (typeof (error as { status?: unknown })?.status === 'number') {
return false
}
return null
}
}
/**
* Convert a Windows path to a Linux path for commands that will execute inside WSL.
* Returns the path unchanged if it is already POSIX-style.
*
* Why: WSL hook/setup environments may need both the worktree UNC path
* (\\wsl.localhost\...) and regular Windows install paths (C:\Users\...)
* translated before passing them to bash. Leaving drive paths untouched
* breaks scripts that read ORCA_ROOT_PATH or similar env vars inside WSL.
*/
export function toLinuxPath(windowsPath: string): string {
const info = parseWslPath(windowsPath)
if (info) {
return info.linuxPath
}
const driveMatch = windowsPath.match(/^([A-Za-z]):[/\\](.*)$/)
if (!driveMatch) {
return windowsPath
}
const driveLetter = driveMatch[1].toLowerCase()
const rest = driveMatch[2].replace(/\\/g, '/')
return `/mnt/${driveLetter}/${rest}`
}
/**
* Convert a Linux path inside a WSL distro to a Windows path.
*
* Why two forms: paths under /mnt/<drive>/... are Windows-native filesystem
* paths that WSL exposes via the DrvFs mount. These map back to their native
* Windows form (e.g. /mnt/c/Users → C:\Users). All other paths live on the
* WSL virtual filesystem and use the UNC form (\\wsl.localhost\Distro\...).
*/
export function toWindowsWslPath(linuxPath: string, distro: string): string {
// /mnt/c/Users/... → C:\Users\...
const mntMatch = linuxPath.match(/^\/mnt\/([a-z])(\/.*)?$/)
if (mntMatch) {
const driveLetter = mntMatch[1].toUpperCase()
const rest = (mntMatch[2] || '').replace(/\//g, '\\')
return `${driveLetter}:${rest || '\\'}`
}
return `\\\\wsl.localhost\\${distro}${linuxPath.replace(/\//g, '\\')}`
}
// ─── 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 async function listWslDistrosAsync(): Promise<string[]> {
if (wslDistroCache !== null) {
return wslDistroCache
}
if (process.platform !== 'win32') {
wslDistroCache = []
return wslDistroCache
}
try {
const output = await execFileUtf8('wsl.exe', ['--list', '--quiet'])
wslDistroCache = normalizeWslListOutput(output).filter(isUserWslDistro)
return wslDistroCache
} catch {
wslDistroCache = []
return wslDistroCache
}
}
export function hasCachedWslDistros(): boolean {
return wslDistroCache !== null
}
export function getCachedWslDistros(): string[] | null {
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.
* Result is cached per distro for the process lifetime.
*
* Why: worktrees for WSL repos are created under ~/orca/workspaces inside
* the WSL filesystem, mirroring the Windows workspace layout. We need the
* WSL user's $HOME to compute that path.
*/
export function getWslHome(distro: string): string | null {
if (wslHomeCache.has(distro)) {
return wslHomeCache.get(distro)!
}
try {
const home = execFileSync('wsl.exe', ['-d', distro, '--', 'bash', '-c', 'echo $HOME'], {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 5000
}).trim()
if (!home || !home.startsWith('/')) {
return null
}
const uncPath = toWindowsWslPath(home, distro)
wslHomeCache.set(distro, uncPath)
return uncPath
} catch {
return null
}
}
export async function getWslHomeAsync(distro: string): Promise<string | null> {
if (wslHomeCache.has(distro)) {
return wslHomeCache.get(distro)!
}
try {
const home = (
await execFileUtf8('wsl.exe', ['-d', distro, '--', 'bash', '-c', 'echo $HOME'])
).trim()
if (!home || !home.startsWith('/')) {
return null
}
const uncPath = toWindowsWslPath(home, distro)
wslHomeCache.set(distro, uncPath)
return uncPath
} catch {
return null
}
}
// Cached WSL availability check — evaluated once per process lifetime
let wslAvailableCache: boolean | null = null
/**
* Check whether wsl.exe is available and functional on this Windows machine.
* Result is cached for the process lifetime.
*/
export function isWslAvailable(): boolean {
if (wslAvailableCache !== null) {
return wslAvailableCache
}
if (process.platform !== 'win32') {
wslAvailableCache = false
return false
}
try {
execFileSync('wsl.exe', ['--status'], {
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 5000
})
wslAvailableCache = true
} catch {
wslAvailableCache = false
}
return wslAvailableCache
}
export function hasCachedWslAvailability(): boolean {
return wslAvailableCache !== null
}
export function getCachedWslAvailability(): boolean | null {
return wslAvailableCache
}
export function _resetWslCachesForTests(): void {
wslHomeCache.clear()
wslDistroCache = null
wslAvailableCache = null
}
export function _setWslCachesForTests(args: {
available?: boolean | null
distros?: string[] | null
}): void {
wslAvailableCache = args.available ?? null
wslDistroCache = args.distros ?? null
}
function execFileUtf8(command: string, args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
execFile(command, args, { encoding: 'utf-8', timeout: 5000 }, (error, stdout) => {
if (error) {
reject(error)
return
}
resolve(stdout)
})
})
}