diff --git a/src/data.js b/src/data.js index 557d875..7bf691c 100644 --- a/src/data.js +++ b/src/data.js @@ -5,9 +5,115 @@ const { execSync, execFileSync } = require('child_process'); // ── Constants ────────────────────────────────────────────── -// Detect WSL and find Windows user home for cross-OS data access +function normalizeProjectPath(value) { + if (!value || typeof value !== 'string') return value || ''; + let normalized = value.trim(); + if (!normalized) return ''; + if (/^\\\\\?\\UNC\\/i.test(normalized)) { + normalized = '\\\\' + normalized.slice(8); + } else if (/^\\\\\?\\[A-Za-z]:\\/i.test(normalized)) { + normalized = normalized.slice(4); + } + return normalized; +} + +function parseWslDistroList(raw) { + const text = Buffer.isBuffer(raw) + ? raw.toString('utf16le').replace(/^\uFEFF/, '') + : String(raw || '').replace(/\0/g, '').replace(/^\uFEFF/, ''); + return text + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); +} + +function getWslDistroList(execFileSyncImpl = execFileSync) { + try { + return parseWslDistroList(execFileSyncImpl('wsl.exe', ['-l', '-q'], { + encoding: 'buffer', + timeout: 3000, + windowsHide: true, + stdio: ['pipe', 'pipe', 'pipe'], + })); + } catch { + return []; + } +} + +function getRunningWslDistroSet(execFileSyncImpl = execFileSync) { + try { + return new Set(parseWslDistroList(execFileSyncImpl('wsl.exe', ['--list', '--quiet', '--running'], { + encoding: 'buffer', + timeout: 3000, + windowsHide: true, + stdio: ['pipe', 'pipe', 'pipe'], + }))); + } catch { + return null; + } +} + +function filterWslDistrosForProcessScan(distros, runningDistros) { + if (!Array.isArray(distros) || distros.length === 0) return []; + if (!(runningDistros instanceof Set)) return []; + return distros.filter((distro) => runningDistros.has(distro)); +} + +function buildWslUncPath(distro, linuxPath) { + if (!distro || !linuxPath || !linuxPath.startsWith('/')) return ''; + return '\\\\wsl$\\' + distro + linuxPath.replace(/\//g, '\\'); +} + +function detectWindowsWslHomes({ + platform = process.platform, + execFileSyncImpl = execFileSync, + fsImpl = fs, + getDistroList = getWslDistroList, + getRunningDistroSet = getRunningWslDistroSet, +} = {}) { + if (platform !== 'win32') return []; + const homes = []; + const distros = filterWslDistrosForProcessScan( + getDistroList(execFileSyncImpl), + getRunningDistroSet(execFileSyncImpl) + ); + for (const distro of distros) { + try { + const linuxHome = String(execFileSyncImpl('wsl.exe', ['-d', distro, 'sh', '-c', 'printf %s "$HOME"'], { + encoding: 'utf8', + timeout: 3000, + windowsHide: true, + stdio: ['pipe', 'pipe', 'pipe'], + }) || '').trim(); + if (!linuxHome || !linuxHome.startsWith('/')) continue; + const uncHome = buildWslUncPath(distro, linuxHome); + if (!uncHome) continue; + const hasRelevantAgentData = [ + path.join(uncHome, '.claude'), + path.join(uncHome, '.codex'), + path.join(uncHome, '.cursor'), + path.join(uncHome, '.local', 'share', 'opencode'), + ].some((candidate) => fsImpl.existsSync(candidate)); + if (hasRelevantAgentData) homes.push(uncHome); + } catch {} + } + return homes; +} + +// Detect cross-OS homes for session data access function detectHomes() { - const homes = [os.homedir()]; + const homes = []; + const seen = new Set(); + const addHome = (home) => { + const normalized = normalizeProjectPath(home); + if (!normalized) return; + const key = process.platform === 'win32' ? normalized.toLowerCase() : normalized; + if (seen.has(key)) return; + seen.add(key); + homes.push(normalized); + }; + + addHome(os.homedir()); // WSL: also check Windows-side home dirs if (process.platform === 'linux' && fs.existsSync('/mnt/c/Users')) { try { @@ -16,22 +122,25 @@ function detectHomes() { // Convert C:\Users\foo to /mnt/c/Users/foo const drive = winUser[0].toLowerCase(); const winPath = '/mnt/' + drive + winUser.slice(2).replace(/\\/g, '/'); - if (fs.existsSync(winPath) && !homes.includes(winPath)) { - homes.push(winPath); - } + if (fs.existsSync(winPath)) addHome(winPath); } } catch { // Fallback: scan /mnt/c/Users/ for directories with .claude try { for (const u of fs.readdirSync('/mnt/c/Users')) { const candidate = '/mnt/c/Users/' + u; - if (fs.existsSync(path.join(candidate, '.claude'))) { - if (!homes.includes(candidate)) homes.push(candidate); - } + if ( + fs.existsSync(path.join(candidate, '.claude')) || + fs.existsSync(path.join(candidate, '.codex')) || + fs.existsSync(path.join(candidate, '.cursor')) + ) addHome(candidate); } } catch {} } } + if (process.platform === 'win32') { + for (const home of detectWindowsWslHomes()) addHome(home); + } return homes; } @@ -79,6 +188,34 @@ function readLines(filePath) { return fs.readFileSync(filePath, 'utf8').split('\n').map(l => l.replace(/\r$/, '')).filter(Boolean); } +function shortenHomePath(value, homes = ALL_HOMES) { + value = normalizeProjectPath(value); + if (!value || typeof value !== 'string') return value || ''; + const valueLower = value.toLowerCase(); + for (const homeRaw of homes) { + const variants = []; + const normalizedHome = normalizeProjectPath(homeRaw); + if (normalizedHome) variants.push(normalizedHome); + const wslMatch = normalizedHome && normalizedHome.match(/^\/mnt\/([a-z])\/(.*)$/i); + if (wslMatch) { + variants.push(wslMatch[1].toUpperCase() + ':\\' + wslMatch[2].replace(/\//g, '\\')); + } + const uncWslMatch = normalizedHome && normalizedHome.match(/^\\\\wsl\$\\[^\\]+\\(.+)$/i); + if (uncWslMatch) { + variants.push('/' + uncWslMatch[1].replace(/\\/g, '/')); + } + for (const home of variants) { + const homeLower = home.toLowerCase(); + if (valueLower === homeLower) return '~'; + if (valueLower.startsWith(homeLower)) { + const nextChar = value.charAt(home.length); + if (nextChar !== '\\' && nextChar !== '/') continue; + return '~' + value.slice(home.length); + } + } + } + return value; +} // OpenCode built-in tools that should NOT be treated as MCP servers const OPENCODE_BUILTIN_TOOLS = new Set([ 'read', 'write', 'edit', 'bash', 'glob', 'grep', 'task', 'todowrite', @@ -3137,4 +3274,14 @@ module.exports = { KIRO_DB, HISTORY_FILE, PROJECTS_DIR, + __test: { + parseWslDistroList, + getWslDistroList, + getRunningWslDistroSet, + filterWslDistrosForProcessScan, + buildWslUncPath, + normalizeProjectPath, + shortenHomePath, + detectWindowsWslHomes, + }, }; diff --git a/test/wsl-windows.test.js b/test/wsl-windows.test.js new file mode 100644 index 0000000..97393ac --- /dev/null +++ b/test/wsl-windows.test.js @@ -0,0 +1,89 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const data = require('../src/data'); + +const { + parseWslDistroList, + getWslDistroList, + getRunningWslDistroSet, + filterWslDistrosForProcessScan, + buildWslUncPath, + normalizeProjectPath, + shortenHomePath, + detectWindowsWslHomes, +} = data.__test; + +test('parseWslDistroList decodes UTF-16LE output and strips BOM/nulls', () => { + const raw = Buffer.from('\uFEFFUbuntu-24.04\r\ndocker-desktop\r\n', 'utf16le'); + assert.deepEqual(parseWslDistroList(raw), ['Ubuntu-24.04', 'docker-desktop']); + assert.deepEqual(parseWslDistroList('\uFEFFUbuntu\0-24.04\r\ndocker\0-desktop\r\n'), ['Ubuntu-24.04', 'docker-desktop']); +}); + +test('WSL list helpers fail safely when WSL is unavailable', () => { + assert.deepEqual(getWslDistroList(() => { throw new Error('ENOENT'); }), []); + assert.equal(getRunningWslDistroSet(() => { throw new Error('disabled'); }), null); +}); + +test('running distro filter keeps only running distros when available', () => { + const running = getRunningWslDistroSet(() => Buffer.from('\uFEFFUbuntu-24.04\r\ndebian\r\n', 'utf16le')); + assert.deepEqual(Array.from(running).sort(), ['Ubuntu-24.04', 'debian']); + assert.deepEqual( + filterWslDistrosForProcessScan(['Ubuntu-24.04', 'docker-desktop', 'debian'], running), + ['Ubuntu-24.04', 'debian'] + ); + assert.deepEqual(filterWslDistrosForProcessScan(['Ubuntu-24.04'], null), []); +}); + +test('buildWslUncPath and shortenHomePath normalize WSL-visible paths', () => { + assert.equal(buildWslUncPath('Ubuntu-24.04', '/home/dius'), '\\\\wsl$\\Ubuntu-24.04\\home\\dius'); + assert.equal(buildWslUncPath('Ubuntu-24.04', '/'), '\\\\wsl$\\Ubuntu-24.04\\'); + assert.equal(buildWslUncPath('', '/home/dius'), ''); + assert.equal(buildWslUncPath('Ubuntu-24.04', 'home/dius'), ''); + assert.equal(shortenHomePath('/home/dius/projects/codedash', ['\\\\wsl$\\Ubuntu-24.04\\home\\dius']), '~/projects/codedash'); + assert.equal(shortenHomePath('\\\\wsl$\\Ubuntu-24.04\\home\\dius\\projects\\codedash', ['\\\\wsl$\\Ubuntu-24.04\\home\\dius']), '~\\projects\\codedash'); + assert.equal(normalizeProjectPath('\\\\?\\C:\\Projects\\codedash'), 'C:\\Projects\\codedash'); +}); + +test('detectWindowsWslHomes discovers only running distros with supported agent data', () => { + const existing = new Set([ + '\\\\wsl$\\Ubuntu-24.04\\home\\dius\\.codex', + '\\\\wsl$\\Debian\\home\\tester\\.cursor', + ]); + const mockFs = { existsSync: (candidate) => existing.has(candidate) }; + const distros = ['Ubuntu-24.04', 'Debian', 'Stopped']; + const running = new Set(['Ubuntu-24.04', 'Debian']); + const homeByDistro = { + 'Ubuntu-24.04': '/home/dius', + Debian: '/home/tester', + Stopped: '/home/stopped', + }; + const execFileSyncImpl = (_exe, args) => { + const distro = args[1]; + return homeByDistro[distro]; + }; + + const homes = detectWindowsWslHomes({ + platform: 'win32', + execFileSyncImpl, + fsImpl: mockFs, + getDistroList: () => distros, + getRunningDistroSet: () => running, + }); + + assert.deepEqual(homes, [ + '\\\\wsl$\\Ubuntu-24.04\\home\\dius', + '\\\\wsl$\\Debian\\home\\tester', + ]); +}); + +test('detectWindowsWslHomes returns empty on non-Windows and does not throw for empty distro lists', () => { + assert.deepEqual(detectWindowsWslHomes({ platform: 'linux' }), []); + assert.deepEqual(detectWindowsWslHomes({ + platform: 'win32', + execFileSyncImpl: () => { throw new Error('should not execute'); }, + fsImpl: { existsSync: () => false }, + getDistroList: () => [], + getRunningDistroSet: () => new Set(), + }), []); +});