Fix Windows path/spawn regressions and session visibility (#552)
* Update OpenCode CLI detection * fix(windows): handle cli file-url import and cmd shim spawn Fixes #533 and #521. * fix(windows): stabilize api path rewrite and session merge Fixes #548.
This commit is contained in:
committed by
GitHub
parent
122a45a890
commit
6eb5d1afa9
+20
-3
@@ -36,6 +36,10 @@ import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||
import type { RuntimeAPIs } from '@/lib/api/types';
|
||||
import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
|
||||
const CLI_MISSING_ERROR_REGEX =
|
||||
/ENOENT|spawn\s+opencode|Unable\s+to\s+locate\s+the\s+opencode\s+CLI|OpenCode\s+CLI\s+not\s+found|opencode(\.exe)?\s+not\s+found|opencode(\.exe)?:\s*command\s+not\s+found|not\s+recognized\s+as\s+an\s+internal\s+or\s+external\s+command|env:\s*['"]?(node|bun)['"]?:\s*No\s+such\s+file\s+or\s+directory|(node|bun):\s*No\s+such\s+file\s+or\s+directory/i;
|
||||
const CLI_ONBOARDING_HEALTH_POLL_MS = 1500;
|
||||
|
||||
const AboutDialogWrapper: React.FC = () => {
|
||||
const { isAboutDialogOpen, setAboutDialogOpen } = useUIStore();
|
||||
return (
|
||||
@@ -237,20 +241,33 @@ function App({ apis }: AppProps) {
|
||||
let cancelled = false;
|
||||
const run = async () => {
|
||||
const res = await fetch('/health', { method: 'GET' }).catch(() => null);
|
||||
if (!res || !res.ok) return;
|
||||
const data = (await res.json().catch(() => null)) as null | { openCodeRunning?: unknown; lastOpenCodeError?: unknown };
|
||||
if (!res || !res.ok || cancelled) return;
|
||||
const data = (await res.json().catch(() => null)) as null | {
|
||||
openCodeRunning?: unknown;
|
||||
isOpenCodeReady?: unknown;
|
||||
opencodeBinaryResolved?: unknown;
|
||||
lastOpenCodeError?: unknown;
|
||||
};
|
||||
if (!data || cancelled) return;
|
||||
const openCodeRunning = data.openCodeRunning === true;
|
||||
const isOpenCodeReady = data.isOpenCodeReady === true;
|
||||
const resolvedBinary = typeof data.opencodeBinaryResolved === 'string' ? data.opencodeBinaryResolved.trim() : '';
|
||||
const hasResolvedBinary = resolvedBinary.length > 0;
|
||||
const err = typeof data.lastOpenCodeError === 'string' ? data.lastOpenCodeError : '';
|
||||
const cliMissing =
|
||||
!openCodeRunning &&
|
||||
/ENOENT|spawn\s+opencode|Unable\s+to\s+locate\s+the\s+opencode\s+CLI|OpenCode\s+CLI\s+not\s+found|opencode(\.exe)?\s+not\s+found|env:\s*(node|bun):\s*No\s+such\s+file\s+or\s+directory|(node|bun):\s*No\s+such\s+file\s+or\s+directory/i.test(err);
|
||||
(CLI_MISSING_ERROR_REGEX.test(err) || (!hasResolvedBinary && !isOpenCodeReady));
|
||||
setShowCliOnboarding(cliMissing);
|
||||
};
|
||||
|
||||
void run();
|
||||
const interval = window.setInterval(() => {
|
||||
void run();
|
||||
}, CLI_ONBOARDING_HEALTH_POLL_MS);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -8,6 +8,10 @@ import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
|
||||
const INSTALL_COMMAND = 'curl -fsSL https://opencode.ai/install | bash';
|
||||
const POLL_INTERVAL_MS = 3000;
|
||||
const DOCS_URL = 'https://opencode.ai/docs';
|
||||
const WINDOWS_WSL_DOCS_URL = 'https://opencode.ai/docs/windows-wsl';
|
||||
|
||||
type OnboardingPlatform = 'macos' | 'linux' | 'windows' | 'unknown';
|
||||
|
||||
type OnboardingScreenProps = {
|
||||
onCliAvailable?: () => void;
|
||||
@@ -42,6 +46,7 @@ export function OnboardingScreen({ onCliAvailable }: OnboardingScreenProps) {
|
||||
const [isDesktopApp, setIsDesktopApp] = React.useState(false);
|
||||
const [isRetrying, setIsRetrying] = React.useState(false);
|
||||
const [opencodeBinary, setOpencodeBinary] = React.useState('');
|
||||
const [platform, setPlatform] = React.useState<OnboardingPlatform>('unknown');
|
||||
|
||||
React.useEffect(() => {
|
||||
const timer = setTimeout(() => setShowHint(true), HINT_DELAY_MS);
|
||||
@@ -52,6 +57,28 @@ export function OnboardingScreen({ onCliAvailable }: OnboardingScreenProps) {
|
||||
setIsDesktopApp(isDesktopShell());
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof navigator === 'undefined') {
|
||||
setPlatform('unknown');
|
||||
return;
|
||||
}
|
||||
|
||||
const ua = navigator.userAgent || '';
|
||||
if (/Windows/i.test(ua)) {
|
||||
setPlatform('windows');
|
||||
return;
|
||||
}
|
||||
if (/Macintosh|Mac OS X/i.test(ua)) {
|
||||
setPlatform('macos');
|
||||
return;
|
||||
}
|
||||
if (/Linux/i.test(ua)) {
|
||||
setPlatform('linux');
|
||||
return;
|
||||
}
|
||||
setPlatform('unknown');
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
@@ -170,6 +197,14 @@ export function OnboardingScreen({ onCliAvailable }: OnboardingScreenProps) {
|
||||
return () => clearInterval(interval);
|
||||
}, [checkCliAvailability, onCliAvailable]);
|
||||
|
||||
const docsUrl = platform === 'windows' ? WINDOWS_WSL_DOCS_URL : DOCS_URL;
|
||||
const binaryPlaceholder =
|
||||
platform === 'windows'
|
||||
? 'C:\\Users\\you\\AppData\\Roaming\\npm\\opencode.cmd'
|
||||
: platform === 'linux'
|
||||
? '/home/you/.bun/bin/opencode'
|
||||
: '/Users/you/.bun/bin/opencode';
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-full flex items-center justify-center bg-transparent p-8 relative cursor-default select-none"
|
||||
@@ -194,6 +229,17 @@ export function OnboardingScreen({ onCliAvailable }: OnboardingScreenProps) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{platform === 'windows' && (
|
||||
<div className="mx-auto max-w-2xl rounded-lg border border-border bg-background/50 p-4 text-left">
|
||||
<div className="text-sm text-foreground">Windows setup (WSL recommended)</div>
|
||||
<ol className="mt-2 list-decimal space-y-1 pl-5 text-sm text-muted-foreground">
|
||||
<li>Install WSL (if needed) with <code className="text-foreground/80">wsl --install</code> in PowerShell.</li>
|
||||
<li>Run the install command below inside your WSL terminal.</li>
|
||||
<li>If OpenChamber does not detect OpenCode automatically, set the binary path below.</li>
|
||||
</ol>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-center">
|
||||
<div className="bg-background/60 backdrop-blur-sm border border-border rounded-lg px-5 py-3 font-mono text-sm w-fit">
|
||||
{copied ? (
|
||||
@@ -208,12 +254,12 @@ export function OnboardingScreen({ onCliAvailable }: OnboardingScreenProps) {
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="https://opencode.ai/docs"
|
||||
href={docsUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-muted-foreground hover:text-foreground transition-colors inline-flex items-center gap-1 justify-center"
|
||||
>
|
||||
View documentation
|
||||
{platform === 'windows' ? 'View Windows + WSL documentation' : 'View documentation'}
|
||||
<RiExternalLinkLine className="h-3 w-3" />
|
||||
</a>
|
||||
|
||||
@@ -239,7 +285,7 @@ export function OnboardingScreen({ onCliAvailable }: OnboardingScreenProps) {
|
||||
<Input
|
||||
value={opencodeBinary}
|
||||
onChange={(e) => setOpencodeBinary(e.target.value)}
|
||||
placeholder="/Users/you/.bun/bin/opencode"
|
||||
placeholder={binaryPlaceholder}
|
||||
disabled={isRetrying}
|
||||
className="flex-1 font-mono text-xs"
|
||||
/>
|
||||
@@ -259,24 +305,35 @@ export function OnboardingScreen({ onCliAvailable }: OnboardingScreenProps) {
|
||||
Apply
|
||||
</Button>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground/70">
|
||||
Saves to <code className="text-foreground/70">~/.config/openchamber/settings.json</code> and reloads OpenCode configuration.
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground/70">Saves to OpenChamber settings and reloads OpenCode configuration.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showHint && (
|
||||
<div className="absolute bottom-8 left-0 right-0 text-center space-y-1">
|
||||
<p className="text-sm text-muted-foreground/70">
|
||||
Already installed? Make sure <code className="text-foreground/70">opencode</code> is in your PATH
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground/70">
|
||||
or set <code className="text-foreground/70">OPENCODE_BINARY</code> environment variable.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground/70">
|
||||
If you see <code className="text-foreground/70">env: node: No such file or directory</code> or <code className="text-foreground/70">env: bun: No such file or directory</code>, install that runtime or ensure it is on PATH.
|
||||
</p>
|
||||
{platform === 'windows' ? (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground/70">
|
||||
On Windows, install and run OpenCode in WSL for best compatibility.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground/70">
|
||||
If detection fails, set a native path (<code className="text-foreground/70">opencode.cmd</code>/<code className="text-foreground/70">opencode.exe</code>), <code className="text-foreground/70">wsl.exe</code>, or <code className="text-foreground/70">wsl:/usr/local/bin/opencode</code>.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-muted-foreground/70">
|
||||
Already installed? Make sure <code className="text-foreground/70">opencode</code> is in your PATH
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground/70">
|
||||
or set <code className="text-foreground/70">OPENCODE_BINARY</code> environment variable.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground/70">
|
||||
If you see <code className="text-foreground/70">env: node: No such file or directory</code> or <code className="text-foreground/70">env: bun: No such file or directory</code>, install that runtime or ensure it is on PATH.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -166,9 +166,49 @@ const normalizeOrigin = (raw: string): string | null => {
|
||||
}
|
||||
};
|
||||
|
||||
const parseUrl = (raw: string): URL | null => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return null;
|
||||
try {
|
||||
return new URL(trimmed);
|
||||
} catch {
|
||||
try {
|
||||
return new URL(trimmed.endsWith('/') ? trimmed : `${trimmed}/`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeHost = (rawHost: string): string => rawHost.replace(/^\[|\]$/g, '').toLowerCase();
|
||||
|
||||
const isLoopbackHost = (host: string): boolean => {
|
||||
const normalized = normalizeHost(host);
|
||||
return normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1';
|
||||
};
|
||||
|
||||
export const isDesktopLocalOriginActive = (): boolean => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const local = typeof window.__OPENCHAMBER_LOCAL_ORIGIN__ === 'string' ? window.__OPENCHAMBER_LOCAL_ORIGIN__ : '';
|
||||
const localUrl = parseUrl(local);
|
||||
const currentUrl = parseUrl(window.location.origin);
|
||||
|
||||
if (localUrl && currentUrl) {
|
||||
if (localUrl.origin === currentUrl.origin) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const localPort = localUrl.port || (localUrl.protocol === 'https:' ? '443' : '80');
|
||||
const currentPort = currentUrl.port || (currentUrl.protocol === 'https:' ? '443' : '80');
|
||||
|
||||
return (
|
||||
localUrl.protocol === currentUrl.protocol &&
|
||||
localPort === currentPort &&
|
||||
isLoopbackHost(localUrl.hostname) &&
|
||||
isLoopbackHost(currentUrl.hostname)
|
||||
);
|
||||
}
|
||||
|
||||
const localOrigin = normalizeOrigin(local);
|
||||
const currentOrigin = normalizeOrigin(window.location.origin) || window.location.origin;
|
||||
return Boolean(localOrigin && currentOrigin && localOrigin === currentOrigin);
|
||||
|
||||
@@ -108,6 +108,16 @@ function isExecutable(filePath: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function shouldUseWindowsShell(binary: string): boolean {
|
||||
if (process.platform !== 'win32') return false;
|
||||
const trimmed = (binary || '').trim();
|
||||
if (!trimmed) return true;
|
||||
const ext = path.extname(trimmed).toLowerCase();
|
||||
if (ext === '.cmd' || ext === '.bat') return true;
|
||||
// Bare command names often resolve to .cmd shims via PATHEXT.
|
||||
return !ext && !trimmed.includes('\\') && !trimmed.includes('/');
|
||||
}
|
||||
|
||||
function appendToPath(dir: string) {
|
||||
const trimmed = (dir || '').trim();
|
||||
if (!trimmed) return;
|
||||
@@ -349,6 +359,7 @@ async function spawnManagedOpenCodeServer(
|
||||
cwd: workingDirectory,
|
||||
env: { ...process.env },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
shell: shouldUseWindowsShell(binary),
|
||||
});
|
||||
|
||||
const url = await new Promise<string>((resolve, reject) => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import path from 'path';
|
||||
import fs from 'fs';
|
||||
import net from 'net';
|
||||
import { spawn, spawnSync } from 'child_process';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { fileURLToPath, pathToFileURL } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
@@ -625,7 +625,7 @@ const commands = {
|
||||
return;
|
||||
}
|
||||
|
||||
const { startWebUiServer } = await import(serverPath);
|
||||
const { startWebUiServer } = await import(pathToFileURL(serverPath).href);
|
||||
await startWebUiServer({
|
||||
port: options.port,
|
||||
attachSignals: true,
|
||||
|
||||
+317
-46
@@ -3398,6 +3398,15 @@ const ENV_EFFECTIVE_PORT = ENV_CONFIGURED_OPENCODE_HOST?.port ?? ENV_CONFIGURED_
|
||||
const ENV_SKIP_OPENCODE_START = process.env.OPENCODE_SKIP_START === 'true' ||
|
||||
process.env.OPENCHAMBER_SKIP_OPENCODE_START === 'true';
|
||||
const ENV_DESKTOP_NOTIFY = process.env.OPENCHAMBER_DESKTOP_NOTIFY === 'true';
|
||||
const ENV_CONFIGURED_OPENCODE_WSL_DISTRO =
|
||||
typeof process.env.OPENCODE_WSL_DISTRO === 'string' && process.env.OPENCODE_WSL_DISTRO.trim().length > 0
|
||||
? process.env.OPENCODE_WSL_DISTRO.trim()
|
||||
: (
|
||||
typeof process.env.OPENCHAMBER_OPENCODE_WSL_DISTRO === 'string' &&
|
||||
process.env.OPENCHAMBER_OPENCODE_WSL_DISTRO.trim().length > 0
|
||||
? process.env.OPENCHAMBER_OPENCODE_WSL_DISTRO.trim()
|
||||
: null
|
||||
);
|
||||
|
||||
// OpenCode server authentication (Basic Auth with username "opencode")
|
||||
|
||||
@@ -3644,6 +3653,10 @@ let resolvedOpencodeBinary = null;
|
||||
let resolvedOpencodeBinarySource = null;
|
||||
let resolvedNodeBinary = null;
|
||||
let resolvedBunBinary = null;
|
||||
let useWslForOpencode = false;
|
||||
let resolvedWslBinary = null;
|
||||
let resolvedWslOpencodePath = null;
|
||||
let resolvedWslDistro = null;
|
||||
|
||||
function isExecutable(filePath) {
|
||||
try {
|
||||
@@ -3682,6 +3695,136 @@ function searchPathFor(binaryName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function isWslExecutableValue(value) {
|
||||
if (typeof value !== 'string') return false;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return false;
|
||||
return /(^|[\\/])wsl(\.exe)?$/i.test(trimmed);
|
||||
}
|
||||
|
||||
function clearWslOpencodeResolution() {
|
||||
useWslForOpencode = false;
|
||||
resolvedWslBinary = null;
|
||||
resolvedWslOpencodePath = null;
|
||||
resolvedWslDistro = null;
|
||||
}
|
||||
|
||||
function resolveWslExecutablePath() {
|
||||
if (process.platform !== 'win32') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const explicit = [process.env.WSL_BINARY, process.env.OPENCHAMBER_WSL_BINARY]
|
||||
.map((v) => (typeof v === 'string' ? v.trim() : ''))
|
||||
.filter(Boolean);
|
||||
|
||||
for (const candidate of explicit) {
|
||||
if (isExecutable(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = spawnSync('where', ['wsl'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
if (result.status === 0) {
|
||||
const lines = (result.stdout || '')
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
const found = lines.find((line) => isExecutable(line));
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
const systemRoot = process.env.SystemRoot || 'C:\\Windows';
|
||||
const fallback = path.join(systemRoot, 'System32', 'wsl.exe');
|
||||
if (isExecutable(fallback)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildWslExecArgs(execArgs, distroOverride = null) {
|
||||
const distro = typeof distroOverride === 'string' && distroOverride.trim().length > 0
|
||||
? distroOverride.trim()
|
||||
: ENV_CONFIGURED_OPENCODE_WSL_DISTRO;
|
||||
|
||||
const prefix = distro ? ['-d', distro] : [];
|
||||
return [...prefix, '--exec', ...execArgs];
|
||||
}
|
||||
|
||||
function probeWslForOpencode() {
|
||||
if (process.platform !== 'win32') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const wslBinary = resolveWslExecutablePath();
|
||||
if (!wslBinary) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = spawnSync(
|
||||
wslBinary,
|
||||
buildWslExecArgs(['sh', '-lc', 'command -v opencode']),
|
||||
{
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
timeout: 6000,
|
||||
},
|
||||
);
|
||||
|
||||
if (result.status !== 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lines = (result.stdout || '')
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
const found = lines[0] || '';
|
||||
if (!found) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
wslBinary,
|
||||
opencodePath: found,
|
||||
distro: ENV_CONFIGURED_OPENCODE_WSL_DISTRO,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function applyWslOpencodeResolution({ wslBinary, opencodePath, source = 'wsl', distro = null } = {}) {
|
||||
const resolvedWsl = wslBinary || resolveWslExecutablePath();
|
||||
if (!resolvedWsl) {
|
||||
return null;
|
||||
}
|
||||
|
||||
useWslForOpencode = true;
|
||||
resolvedWslBinary = resolvedWsl;
|
||||
resolvedWslOpencodePath = typeof opencodePath === 'string' && opencodePath.trim().length > 0
|
||||
? opencodePath.trim()
|
||||
: 'opencode';
|
||||
resolvedWslDistro = typeof distro === 'string' && distro.trim().length > 0 ? distro.trim() : ENV_CONFIGURED_OPENCODE_WSL_DISTRO;
|
||||
resolvedOpencodeBinary = `wsl:${resolvedWslOpencodePath}`;
|
||||
resolvedOpencodeBinarySource = source;
|
||||
|
||||
// Keep OPENCODE_BINARY empty in WSL mode to avoid native spawn attempts.
|
||||
delete process.env.OPENCODE_BINARY;
|
||||
return resolvedOpencodeBinary;
|
||||
}
|
||||
|
||||
function resolveOpencodeCliPath() {
|
||||
const explicit = [
|
||||
process.env.OPENCODE_BINARY,
|
||||
@@ -3694,6 +3837,7 @@ function resolveOpencodeCliPath() {
|
||||
|
||||
for (const candidate of explicit) {
|
||||
if (isExecutable(candidate)) {
|
||||
clearWslOpencodeResolution();
|
||||
resolvedOpencodeBinarySource = 'env';
|
||||
return candidate;
|
||||
}
|
||||
@@ -3701,6 +3845,7 @@ function resolveOpencodeCliPath() {
|
||||
|
||||
const resolvedFromPath = searchPathFor('opencode');
|
||||
if (resolvedFromPath) {
|
||||
clearWslOpencodeResolution();
|
||||
resolvedOpencodeBinarySource = 'path';
|
||||
return resolvedFromPath;
|
||||
}
|
||||
@@ -3739,6 +3884,7 @@ function resolveOpencodeCliPath() {
|
||||
const fallbacks = process.platform === 'win32' ? winFallbacks : unixFallbacks;
|
||||
for (const candidate of fallbacks) {
|
||||
if (isExecutable(candidate)) {
|
||||
clearWslOpencodeResolution();
|
||||
resolvedOpencodeBinarySource = 'fallback';
|
||||
return candidate;
|
||||
}
|
||||
@@ -3757,6 +3903,7 @@ function resolveOpencodeCliPath() {
|
||||
.filter(Boolean);
|
||||
const found = lines.find((line) => isExecutable(line));
|
||||
if (found) {
|
||||
clearWslOpencodeResolution();
|
||||
resolvedOpencodeBinarySource = 'where';
|
||||
return found;
|
||||
}
|
||||
@@ -3764,6 +3911,15 @@ function resolveOpencodeCliPath() {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const wsl = probeWslForOpencode();
|
||||
if (wsl) {
|
||||
return applyWslOpencodeResolution({
|
||||
wslBinary: wsl.wslBinary,
|
||||
opencodePath: wsl.opencodePath,
|
||||
source: 'wsl',
|
||||
distro: wsl.distro,
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -3778,6 +3934,7 @@ function resolveOpencodeCliPath() {
|
||||
if (result.status === 0) {
|
||||
const found = (result.stdout || '').trim().split(/\s+/).pop() || '';
|
||||
if (found && isExecutable(found)) {
|
||||
clearWslOpencodeResolution();
|
||||
resolvedOpencodeBinarySource = 'shell';
|
||||
return found;
|
||||
}
|
||||
@@ -4059,10 +4216,44 @@ async function applyOpencodeBinaryFromSettings() {
|
||||
delete process.env.OPENCODE_BINARY;
|
||||
resolvedOpencodeBinary = null;
|
||||
resolvedOpencodeBinarySource = null;
|
||||
clearWslOpencodeResolution();
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = typeof settings.opencodeBinary === 'string' ? settings.opencodeBinary.trim() : '';
|
||||
|
||||
const explicitWslPath = process.platform === 'win32' && typeof raw === 'string'
|
||||
? raw.match(/^wsl:\s*(.+)$/i)
|
||||
: null;
|
||||
|
||||
if (explicitWslPath && explicitWslPath[1] && explicitWslPath[1].trim().length > 0) {
|
||||
const probe = probeWslForOpencode();
|
||||
const applied = applyWslOpencodeResolution({
|
||||
wslBinary: probe?.wslBinary || resolveWslExecutablePath(),
|
||||
opencodePath: explicitWslPath[1].trim(),
|
||||
source: 'settings-wsl-path',
|
||||
distro: probe?.distro || ENV_CONFIGURED_OPENCODE_WSL_DISTRO,
|
||||
});
|
||||
if (applied) {
|
||||
return applied;
|
||||
}
|
||||
}
|
||||
|
||||
if (process.platform === 'win32' && (isWslExecutableValue(raw) || isWslExecutableValue(normalized || ''))) {
|
||||
const probe = probeWslForOpencode();
|
||||
const applied = applyWslOpencodeResolution({
|
||||
wslBinary: probe?.wslBinary || normalized || raw || null,
|
||||
opencodePath: probe?.opencodePath || 'opencode',
|
||||
source: 'settings-wsl',
|
||||
distro: probe?.distro || ENV_CONFIGURED_OPENCODE_WSL_DISTRO,
|
||||
});
|
||||
if (applied) {
|
||||
return applied;
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized && isExecutable(normalized)) {
|
||||
clearWslOpencodeResolution();
|
||||
process.env.OPENCODE_BINARY = normalized;
|
||||
prependToPath(path.dirname(normalized));
|
||||
resolvedOpencodeBinary = normalized;
|
||||
@@ -4071,7 +4262,6 @@ async function applyOpencodeBinaryFromSettings() {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const raw = typeof settings.opencodeBinary === 'string' ? settings.opencodeBinary.trim() : '';
|
||||
if (raw) {
|
||||
console.warn(`Configured settings.opencodeBinary is not executable: ${raw}`);
|
||||
}
|
||||
@@ -4084,12 +4274,16 @@ async function applyOpencodeBinaryFromSettings() {
|
||||
|
||||
function ensureOpencodeCliEnv() {
|
||||
if (resolvedOpencodeBinary) {
|
||||
if (useWslForOpencode) {
|
||||
return resolvedOpencodeBinary;
|
||||
}
|
||||
ensureOpencodeShimRuntime(resolvedOpencodeBinary);
|
||||
return resolvedOpencodeBinary;
|
||||
}
|
||||
|
||||
const existing = typeof process.env.OPENCODE_BINARY === 'string' ? process.env.OPENCODE_BINARY.trim() : '';
|
||||
if (existing && isExecutable(existing)) {
|
||||
clearWslOpencodeResolution();
|
||||
resolvedOpencodeBinary = existing;
|
||||
resolvedOpencodeBinarySource = resolvedOpencodeBinarySource || 'env';
|
||||
prependToPath(path.dirname(existing));
|
||||
@@ -4099,6 +4293,13 @@ function ensureOpencodeCliEnv() {
|
||||
|
||||
const resolved = resolveOpencodeCliPath();
|
||||
if (resolved) {
|
||||
if (useWslForOpencode) {
|
||||
resolvedOpencodeBinary = resolved;
|
||||
resolvedOpencodeBinarySource = resolvedOpencodeBinarySource || 'wsl';
|
||||
console.log(`Resolved opencode CLI via WSL: ${resolvedWslOpencodePath || 'opencode'}`);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
process.env.OPENCODE_BINARY = resolved;
|
||||
prependToPath(path.dirname(resolved));
|
||||
ensureOpencodeShimRuntime(resolved);
|
||||
@@ -4108,6 +4309,7 @@ function ensureOpencodeCliEnv() {
|
||||
return resolved;
|
||||
}
|
||||
|
||||
clearWslOpencodeResolution();
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -5171,12 +5373,34 @@ async function createManagedOpenCodeServerProcess({
|
||||
env,
|
||||
}) {
|
||||
let binary = (process.env.OPENCODE_BINARY || 'opencode').trim() || 'opencode';
|
||||
const args = ['serve', '--hostname', hostname, '--port', String(port)];
|
||||
let args = ['serve', '--hostname', hostname, '--port', String(port)];
|
||||
|
||||
if (process.platform === 'win32' && useWslForOpencode) {
|
||||
const wslBinary = resolvedWslBinary || resolveWslExecutablePath();
|
||||
if (!wslBinary) {
|
||||
throw new Error('WSL executable not found while attempting to launch OpenCode from WSL');
|
||||
}
|
||||
|
||||
const wslOpencode = resolvedWslOpencodePath && resolvedWslOpencodePath.trim().length > 0
|
||||
? resolvedWslOpencodePath.trim()
|
||||
: 'opencode';
|
||||
const serveHost = hostname === '127.0.0.1' ? '0.0.0.0' : hostname;
|
||||
|
||||
binary = wslBinary;
|
||||
args = buildWslExecArgs([
|
||||
wslOpencode,
|
||||
'serve',
|
||||
'--hostname',
|
||||
serveHost,
|
||||
'--port',
|
||||
String(port),
|
||||
], resolvedWslDistro);
|
||||
}
|
||||
|
||||
// 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') {
|
||||
if (process.platform === 'win32' && !useWslForOpencode) {
|
||||
const interpreter = opencodeShimInterpreter(binary);
|
||||
if (interpreter) {
|
||||
// Binary itself has a node/bun shebang – spawn via that interpreter.
|
||||
@@ -5675,29 +5899,53 @@ 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;
|
||||
}
|
||||
const stripApiPrefix = (rawUrl) => {
|
||||
if (typeof rawUrl !== 'string' || !rawUrl) {
|
||||
return '/';
|
||||
}
|
||||
if (rawUrl === '/api') {
|
||||
return '/';
|
||||
}
|
||||
if (rawUrl.startsWith('/api/')) {
|
||||
return rawUrl.slice(4);
|
||||
}
|
||||
return rawUrl;
|
||||
};
|
||||
|
||||
// Keep route matching stable; only rewrite the proxied upstream path.
|
||||
const rewriteWindowsDirectoryParam = (upstreamPath) => {
|
||||
if (process.platform !== 'win32') {
|
||||
return upstreamPath;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(upstreamPath, 'http://openchamber.local');
|
||||
const pathname = parsed.pathname || '/';
|
||||
if (pathname === '/session' || pathname.startsWith('/session/')) {
|
||||
return upstreamPath;
|
||||
}
|
||||
next();
|
||||
});
|
||||
}
|
||||
const directory = parsed.searchParams.get('directory');
|
||||
if (!directory || !directory.includes('/')) {
|
||||
return upstreamPath;
|
||||
}
|
||||
const fixed = directory.replace(/\//g, '\\');
|
||||
parsed.searchParams.set('directory', fixed);
|
||||
const rewritten = `${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||||
if (rewritten !== upstreamPath) {
|
||||
console.log(`[Win32PathFix] Rewrote directory: "${directory}" → "${fixed}"`);
|
||||
console.log(`[Win32PathFix] URL: "${upstreamPath}" → "${rewritten}"`);
|
||||
}
|
||||
return rewritten;
|
||||
} catch {
|
||||
return upstreamPath;
|
||||
}
|
||||
};
|
||||
|
||||
const getUpstreamPathForRequest = (req) => {
|
||||
const rawUrl = (typeof req.originalUrl === 'string' && req.originalUrl)
|
||||
? req.originalUrl
|
||||
: (typeof req.url === 'string' ? req.url : '/');
|
||||
return rewriteWindowsDirectoryParam(stripApiPrefix(rawUrl));
|
||||
};
|
||||
|
||||
app.use('/api', (req, res, next) => {
|
||||
if (
|
||||
@@ -5733,7 +5981,7 @@ function setupProxy(app) {
|
||||
|
||||
const forwardSseRequest = async (req, res) => {
|
||||
const startedAt = Date.now();
|
||||
const upstreamPath = req.originalUrl.replace(/^\/api/, '');
|
||||
const upstreamPath = getUpstreamPathForRequest(req);
|
||||
const targetUrl = buildOpenCodeUrl(upstreamPath, '');
|
||||
const authHeaders = getOpenCodeAuthHeaders();
|
||||
|
||||
@@ -5970,7 +6218,7 @@ function setupProxy(app) {
|
||||
|
||||
const forwardGenericApiRequest = async (req, res) => {
|
||||
try {
|
||||
const upstreamPath = req.originalUrl.replace(/^\/api/, '');
|
||||
const upstreamPath = getUpstreamPathForRequest(req);
|
||||
const targetUrl = buildOpenCodeUrl(upstreamPath, '');
|
||||
const headers = collectForwardHeaders(req);
|
||||
const method = String(req.method || 'GET').toUpperCase();
|
||||
@@ -6008,7 +6256,7 @@ function setupProxy(app) {
|
||||
// This avoids edge-cases in generic proxy streaming for multi-file attachments.
|
||||
app.post('/api/session/:sessionId/message', express.raw({ type: '*/*', limit: '50mb' }), async (req, res) => {
|
||||
try {
|
||||
const upstreamPath = req.originalUrl.replace(/^\/api/, '');
|
||||
const upstreamPath = getUpstreamPathForRequest(req);
|
||||
const targetUrl = buildOpenCodeUrl(upstreamPath, '');
|
||||
const authHeaders = getOpenCodeAuthHeaders();
|
||||
|
||||
@@ -6067,7 +6315,8 @@ function setupProxy(app) {
|
||||
signal: AbortSignal.timeout(10000),
|
||||
};
|
||||
const globalRes = await fetch(buildOpenCodeUrl('/session', ''), fetchOpts);
|
||||
const globalSessions = globalRes.ok ? (await globalRes.json()) : [];
|
||||
const globalPayload = globalRes.ok ? await globalRes.json().catch(() => []) : [];
|
||||
const globalSessions = Array.isArray(globalPayload) ? globalPayload : [];
|
||||
|
||||
const settingsPath = path.join(os.homedir(), '.config', 'openchamber', 'settings.json');
|
||||
let projectDirs = [];
|
||||
@@ -6075,33 +6324,47 @@ function setupProxy(app) {
|
||||
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);
|
||||
.map((project) => (typeof project?.path === 'string' ? project.path.trim() : ''))
|
||||
.filter(Boolean);
|
||||
} catch {}
|
||||
|
||||
const seen = new Set(globalSessions.map(s => s.id));
|
||||
const seen = new Set(
|
||||
globalSessions
|
||||
.map((session) => (session && typeof session.id === 'string' ? session.id : null))
|
||||
.filter((id) => typeof id === 'string')
|
||||
);
|
||||
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);
|
||||
const candidates = Array.from(new Set([
|
||||
dir,
|
||||
dir.replace(/\\/g, '/'),
|
||||
dir.replace(/\//g, '\\'),
|
||||
]));
|
||||
for (const candidateDir of candidates) {
|
||||
const encoded = encodeURIComponent(candidateDir);
|
||||
try {
|
||||
const dirRes = await fetch(buildOpenCodeUrl(`/session?directory=${encoded}`, ''), fetchOpts);
|
||||
if (dirRes.ok) {
|
||||
const dirPayload = await dirRes.json().catch(() => []);
|
||||
const dirSessions = Array.isArray(dirPayload) ? dirPayload : [];
|
||||
for (const session of dirSessions) {
|
||||
const id = session && typeof session.id === 'string' ? session.id : null;
|
||||
if (id && !seen.has(id)) {
|
||||
seen.add(id);
|
||||
extraSessions.push(session);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
const merged = [...globalSessions, ...extraSessions];
|
||||
merged.sort((a, b) => (b.time_updated || 0) - (a.time_updated || 0));
|
||||
merged.sort((a, b) => {
|
||||
const aTime = a && typeof a.time_updated === 'number' ? a.time_updated : 0;
|
||||
const bTime = b && typeof b.time_updated === 'number' ? b.time_updated : 0;
|
||||
return bTime - aTime;
|
||||
});
|
||||
console.log(`[SessionMerge] ${globalSessions.length} global + ${extraSessions.length} extra = ${merged.length} total`);
|
||||
return res.json(merged);
|
||||
} catch (error) {
|
||||
@@ -6310,6 +6573,10 @@ async function main(options = {}) {
|
||||
opencodeBinaryResolved: resolvedOpencodeBinary || null,
|
||||
opencodeBinarySource: resolvedOpencodeBinarySource || null,
|
||||
opencodeShimInterpreter: resolvedOpencodeBinary ? opencodeShimInterpreter(resolvedOpencodeBinary) : null,
|
||||
opencodeViaWsl: useWslForOpencode,
|
||||
opencodeWslBinary: resolvedWslBinary || null,
|
||||
opencodeWslPath: resolvedWslOpencodePath || null,
|
||||
opencodeWslDistro: resolvedWslDistro || null,
|
||||
nodeBinaryResolved: resolvedNodeBinary || null,
|
||||
bunBinaryResolved: resolvedBunBinary || null,
|
||||
});
|
||||
@@ -7687,6 +7954,10 @@ async function main(options = {}) {
|
||||
detectedNow,
|
||||
detectedSourceNow,
|
||||
shim,
|
||||
viaWsl: useWslForOpencode,
|
||||
wslBinary: resolvedWslBinary || null,
|
||||
wslPath: resolvedWslOpencodePath || null,
|
||||
wslDistro: resolvedWslDistro || null,
|
||||
node: resolvedNodeBinary || null,
|
||||
bun: resolvedBunBinary || null,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user