feat(wsl): improve WSL path handling and output decoding

- Refactored `toWslUncPath` to remove the prefix parameter for consistency.
- Added `looksLikeUtf16Le` and `decodeWslOutput` functions to enhance output decoding from WSL commands.
- Updated `listWslDistros` to utilize a command array for better command execution handling.
- Simplified home path resolution in `handleFindWslClaudeRoots` to avoid duplicates and improve clarity.
This commit is contained in:
matt 2026-02-14 14:17:42 +09:00
parent 5c563f40ec
commit 0a987b8aea

View file

@ -744,9 +744,9 @@ function normalizeWslHomePath(home: string): string | null {
return normalized; return normalized;
} }
function toWslUncPath(prefix: '\\\\wsl.localhost' | '\\\\wsl$', distro: string, posixPath: string): string { function toWslUncPath(distro: string, posixPath: string): string {
const uncSuffix = posixPath.replace(/\//g, '\\'); const uncSuffix = posixPath.replace(/\//g, '\\');
return `${prefix}\\${distro}${uncSuffix}`; return `\\\\wsl.localhost\\${distro}${uncSuffix}`;
} }
function getWslExecutableCandidates(): string[] { function getWslExecutableCandidates(): string[] {
@ -762,16 +762,54 @@ function getWslExecutableCandidates(): string[] {
return Array.from(candidates); return Array.from(candidates);
} }
function 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;
}
function decodeWslOutput(output: string | Buffer | undefined): string {
if (typeof output === 'string') {
return output.replace(/\0/g, '');
}
if (!output || output.length === 0) {
return '';
}
const hasUtf16LeBom = output.length >= 2 && output[0] === 0xff && output[1] === 0xfe;
const decoded = hasUtf16LeBom || looksLikeUtf16Le(output)
? output.toString('utf16le')
: output.toString('utf8');
return decoded.replace(/\0/g, '');
}
async function runWsl(args: string[], timeout = 5000): Promise<{ stdout: string; stderr: string }> { async function runWsl(args: string[], timeout = 5000): Promise<{ stdout: string; stderr: string }> {
const candidates = getWslExecutableCandidates(); const candidates = getWslExecutableCandidates();
let lastError: unknown = null; let lastError: unknown = null;
for (const executable of candidates) { for (const executable of candidates) {
try { try {
const result = await execFileAsync(executable, args, { timeout }); const result = await execFileAsync(executable, args, {
timeout,
windowsHide: true,
maxBuffer: 1024 * 1024,
encoding: 'buffer',
});
return { return {
stdout: result.stdout ?? '', stdout: decodeWslOutput(result.stdout),
stderr: result.stderr ?? '', stderr: decodeWslOutput(result.stderr),
}; };
} catch (error) { } catch (error) {
lastError = error; lastError = error;
@ -781,24 +819,59 @@ async function runWsl(args: string[], timeout = 5000): Promise<{ stdout: string;
throw lastError instanceof Error ? lastError : new Error('Unable to execute wsl.exe'); throw lastError instanceof Error ? lastError : new Error('Unable to execute wsl.exe');
} }
async function listWslDistros(): Promise<string[]> { function parseWslDistros(stdout: string): string[] {
try { const distros: string[] = [];
const { stdout } = await runWsl(['-l', '-q'], 4000); const seen = new Set<string>();
return stdout const lines = stdout.split(/\r?\n/);
.split(/\r?\n/)
.map((line) => line.trim()) for (const rawLine of lines) {
.filter((line) => line.length > 0); let line = rawLine.replace(/\0/g, '').trim();
} catch { if (!line) {
// Fallback for environments where -q behavior is inconsistent. continue;
const { stdout } = await runWsl(['-l'], 4000); }
return stdout
.split(/\r?\n/) line = line.replace(/^\*\s*/, '').trim();
.map((line) => line.trim()) line = stripDefaultSuffix(line);
.filter((line) => line.length > 0)
.filter((line) => !line.toLowerCase().startsWith('windows subsystem for linux')) const lower = line.toLowerCase();
.filter((line) => !line.toLowerCase().includes('default version')) if (
.map(stripDefaultSuffix); lower.startsWith('windows subsystem for linux') ||
lower.includes('default version') ||
lower.startsWith('the following is a list')
) {
continue;
}
const key = line.toLowerCase();
if (!seen.has(key)) {
seen.add(key);
distros.push(line);
}
} }
return distros;
}
async function listWslDistros(): Promise<string[]> {
const commands: string[][] = [
['--list', '--quiet'],
['-l', '-q'],
['-l'],
];
for (const command of commands) {
try {
const { stdout } = await runWsl(command, 4000);
const parsed = parseWslDistros(stdout);
if (parsed.length > 0) {
return parsed;
}
} catch {
// Try the next command variant.
}
}
return [];
} }
function stripDefaultSuffix(input: string): string { function stripDefaultSuffix(input: string): string {
@ -841,50 +914,38 @@ async function handleFindWslClaudeRoots(
const candidates: WslClaudeRootCandidate[] = []; const candidates: WslClaudeRootCandidate[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
for (const distro of distros) { for (const distro of distros) {
const homePath = await resolveWslHome(distro); const resolvedHomePath = await resolveWslHome(distro);
const homeCandidates = new Set<string>(); const fallbackHomePath = process.env.USERNAME ? `/home/${process.env.USERNAME}` : null;
if (homePath) { const normalizedHome =
homeCandidates.add(homePath); normalizeWslHomePath(resolvedHomePath ?? '') ??
} (fallbackHomePath ? normalizeWslHomePath(fallbackHomePath) : null);
if (process.env.USERNAME) {
homeCandidates.add(`/home/${process.env.USERNAME}`);
}
homeCandidates.add('/root');
for (const homeCandidate of homeCandidates) { if (!normalizedHome) {
const normalizedHome = normalizeWslHomePath(homeCandidate); continue;
if (!normalizedHome) { }
continue;
const claudePosixPath = path.posix.join(normalizedHome, '.claude');
const claudeUncPath = toWslUncPath(distro, claudePosixPath);
const key = claudeUncPath.toLowerCase();
if (seen.has(key)) {
continue;
}
seen.add(key);
const projectsPath = path.join(claudeUncPath, 'projects');
const hasProjectsDir = (() => {
try {
return fs.existsSync(projectsPath) && fs.statSync(projectsPath).isDirectory();
} catch {
return false;
} }
const claudePosixPath = path.posix.join(normalizedHome, '.claude'); })();
const uncPaths = [ candidates.push({
toWslUncPath('\\\\wsl.localhost', distro, claudePosixPath), distro,
toWslUncPath('\\\\wsl$', distro, claudePosixPath), path: claudeUncPath,
]; hasProjectsDir,
});
for (const claudeUncPath of uncPaths) {
if (seen.has(claudeUncPath.toLowerCase())) {
continue;
}
seen.add(claudeUncPath.toLowerCase());
const projectsPath = path.join(claudeUncPath, 'projects');
const hasProjectsDir = (() => {
try {
return fs.existsSync(projectsPath) && fs.statSync(projectsPath).isDirectory();
} catch {
return false;
}
})();
candidates.push({
distro,
path: claudeUncPath,
hasProjectsDir,
});
}
}
} }
return { success: true, data: candidates }; return { success: true, data: candidates };