From dc0f9b3e625f13b1d1f00741c9f933c51418c056 Mon Sep 17 00:00:00 2001 From: marco mereu Date: Thu, 26 Feb 2026 19:21:31 +0100 Subject: [PATCH] Fix Windows compatibility: git status check, OpenCode spawn, path normalization, session merge (#527) - Add isGitRepository check before getStatus() to prevent noisy errors on non-git directories - Resolve npm shell wrapper shims on Windows so Bun can spawn OpenCode via node interpreter - Add Win32PathFix middleware to normalize forward slashes to backslashes in directory params - Merge sessions from all project directories on bare GET /session for Windows path compat Co-authored-by: Claude Opus 4.6 --- packages/web/server/index.js | 122 ++++++++++++++++++++++++++++++++++- 1 file changed, 119 insertions(+), 3 deletions(-) diff --git a/packages/web/server/index.js b/packages/web/server/index.js index f3ddf315..f5b0466e 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -4672,8 +4672,40 @@ async function createManagedOpenCodeServerProcess({ cwd, env, }) { - const binary = (process.env.OPENCODE_BINARY || 'opencode').trim() || 'opencode'; + let binary = (process.env.OPENCODE_BINARY || 'opencode').trim() || 'opencode'; const args = ['serve', '--hostname', hostname, '--port', String(port)]; + + // On Windows, Bun/Node cannot directly spawn shell wrapper scripts (#!/bin/sh). + // Detect if the resolved binary is a shim that wraps a Node/Bun script and + // resolve the actual target so we can spawn it with the correct interpreter. + if (process.platform === 'win32') { + const interpreter = opencodeShimInterpreter(binary); + if (interpreter) { + // Binary itself has a node/bun shebang – spawn via that interpreter. + args.unshift(binary); + binary = interpreter; + } else { + // The wrapper might be a shell shim generated by npm. Try to find the + // real JS entry point next to it (e.g. node_modules/opencode-ai/bin/opencode). + try { + const shimContent = fs.readFileSync(binary, 'utf8'); + const jsMatch = shimContent.match(/node_modules[\\/]opencode[^\s"']*/); + if (jsMatch) { + const candidate = path.resolve(path.dirname(binary), jsMatch[0]); + if (fs.existsSync(candidate)) { + const realInterp = opencodeShimInterpreter(candidate); + if (realInterp) { + args.unshift(candidate); + binary = realInterp; + } + } + } + } catch { + // ignore – fall through to default spawn + } + } + } + const child = spawn(binary, args, { cwd, env, @@ -5145,6 +5177,30 @@ function setupProxy(app) { } app.set('opencodeProxyConfigured', true); + // Windows path normalization: OpenCode CLI stores paths with backslashes in the DB, + // but the frontend sends forward slashes. Rewrite directory query params on Windows. + // Must run BEFORE all other /api middleware. + if (process.platform === 'win32') { + app.use('/api', (req, _res, next) => { + // Parse directory from the raw URL since Express query parsing may not be available + const rawUrl = req.originalUrl || req.url || ''; + const dirMatch = rawUrl.match(/[?&]directory=([^&]*)/); + if (dirMatch) { + const decoded = decodeURIComponent(dirMatch[1]); + if (decoded.includes('/')) { + const fixed = decoded.replace(/\//g, '\\'); + const fixedEncoded = encodeURIComponent(fixed); + const newUrl = rawUrl.replace(/([?&]directory=)[^&]*/, '$1' + fixedEncoded); + console.log(`[Win32PathFix] Rewrote directory: "${decoded}" → "${fixed}"`); + console.log(`[Win32PathFix] URL: "${rawUrl}" → "${newUrl}"`); + req.originalUrl = newUrl; + req.url = newUrl; + } + } + next(); + }); + } + app.use('/api', (req, res, next) => { if ( req.path.startsWith('/themes/custom') || @@ -5492,7 +5548,7 @@ function setupProxy(app) { } }); - app.use('/api', (req, res, next) => { + app.use('/api', async (req, res, next) => { if (isSseApiPath(req.path)) { return next(); } @@ -5501,6 +5557,61 @@ function setupProxy(app) { return next(); } + // Windows: Merge sessions from all project directories on bare GET /session + if (process.platform === 'win32' && req.method === 'GET' && req.path === '/session') { + const rawUrl = req.originalUrl || req.url || ''; + if (!rawUrl.includes('directory=')) { + try { + const authHeaders = getOpenCodeAuthHeaders(); + const fetchOpts = { + method: 'GET', + headers: { Accept: 'application/json', ...authHeaders }, + signal: AbortSignal.timeout(10000), + }; + const globalRes = await fetch(buildOpenCodeUrl('/session', ''), fetchOpts); + const globalSessions = globalRes.ok ? (await globalRes.json()) : []; + + const settingsPath = path.join(os.homedir(), '.config', 'openchamber', 'settings.json'); + let projectDirs = []; + try { + const settingsRaw = fs.readFileSync(settingsPath, 'utf8'); + const settings = JSON.parse(settingsRaw); + projectDirs = (settings.projects || []) + .map(p => p.path) + .filter(p => typeof p === 'string' && p.length > 0); + } catch {} + + const seen = new Set(globalSessions.map(s => s.id)); + const extraSessions = []; + for (const dir of projectDirs) { + const backslashDir = dir.replace(/\//g, '\\'); + const encoded = encodeURIComponent(backslashDir); + try { + const dirRes = await fetch(buildOpenCodeUrl(`/session?directory=${encoded}`, ''), fetchOpts); + if (dirRes.ok) { + const dirSessions = await dirRes.json(); + if (Array.isArray(dirSessions)) { + for (const s of dirSessions) { + if (s && s.id && !seen.has(s.id)) { + seen.add(s.id); + extraSessions.push(s); + } + } + } + } + } catch {} + } + + const merged = [...globalSessions, ...extraSessions]; + merged.sort((a, b) => (b.time_updated || 0) - (a.time_updated || 0)); + console.log(`[SessionMerge] ${globalSessions.length} global + ${extraSessions.length} extra = ${merged.length} total`); + return res.json(merged); + } catch (error) { + console.log(`[SessionMerge] Error: ${error.message}, falling through`); + } + } + } + return forwardGenericApiRequest(req, res); }); } @@ -9604,13 +9715,18 @@ async function main(options = {}) { }); app.get('/api/git/status', async (req, res) => { - const { getStatus } = await getGitLibraries(); + const { getStatus, isGitRepository } = await getGitLibraries(); try { const directory = req.query.directory; if (!directory) { return res.status(400).json({ error: 'directory parameter is required' }); } + const isRepo = await isGitRepository(directory); + if (!isRepo) { + return res.json({ isGitRepository: false, files: [], branch: null, ahead: 0, behind: 0 }); + } + const status = await getStatus(directory); res.json(status); } catch (error) {