1106 lines
33 KiB
TypeScript
1106 lines
33 KiB
TypeScript
import * as vscode from 'vscode';
|
|
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import * as fs from 'fs';
|
|
import * as net from 'net';
|
|
import { execSync } from 'child_process';
|
|
import { spawnSync } from 'child_process';
|
|
import { spawn } from 'child_process';
|
|
import { randomBytes } from 'crypto';
|
|
import { normalizeWindowsDriveLetter } from './pathUtils';
|
|
|
|
const READY_CHECK_TIMEOUT_MS = 30000;
|
|
const WINDOWS_EXECUTABLE_EXTENSIONS = (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM')
|
|
.split(';')
|
|
.map((ext) => ext.trim().toLowerCase())
|
|
.filter(Boolean)
|
|
.map((ext) => (ext.startsWith('.') ? ext : `.${ext}`));
|
|
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error';
|
|
|
|
export type OpenCodeDebugInfo = {
|
|
mode: 'managed' | 'external';
|
|
status: ConnectionStatus;
|
|
lastError?: string;
|
|
workingDirectory: string;
|
|
cliAvailable: boolean;
|
|
cliPath: string | null;
|
|
configuredApiUrl: string | null;
|
|
configuredPort: number | null;
|
|
detectedPort: number | null;
|
|
apiPrefix: string;
|
|
apiPrefixDetected: boolean;
|
|
startCount: number;
|
|
restartCount: number;
|
|
lastStartAt: number | null;
|
|
lastConnectedAt: number | null;
|
|
lastExitCode: number | null;
|
|
serverUrl: string | null;
|
|
lastReadyElapsedMs: number | null;
|
|
lastReadyAttempts: number | null;
|
|
lastStartAttempts: number | null;
|
|
version: string | null;
|
|
secureConnection: boolean;
|
|
authSource: 'user-env' | 'generated' | 'rotated' | null;
|
|
};
|
|
|
|
export interface OpenCodeManager {
|
|
start(workdir?: string): Promise<void>;
|
|
stop(): Promise<void>;
|
|
restart(): Promise<void>;
|
|
setWorkingDirectory(path: string): Promise<{ success: boolean; restarted: boolean; path: string }>;
|
|
getStatus(): ConnectionStatus;
|
|
getApiUrl(): string | null;
|
|
getOpenCodeAuthHeaders(): Record<string, string>;
|
|
getWorkingDirectory(): string;
|
|
isCliAvailable(): boolean;
|
|
getDebugInfo(): OpenCodeDebugInfo;
|
|
onStatusChange(callback: (status: ConnectionStatus, error?: string) => void): vscode.Disposable;
|
|
}
|
|
|
|
function generateSecureOpenCodePassword(): string {
|
|
return randomBytes(32)
|
|
.toString('base64')
|
|
.replace(/\+/g, '-')
|
|
.replace(/\//g, '_')
|
|
.replace(/=+$/g, '');
|
|
}
|
|
|
|
function buildOpenCodeAuthHeader(password: string): string {
|
|
return `Basic ${Buffer.from(`opencode:${password}`, 'utf8').toString('base64')}`;
|
|
}
|
|
|
|
function isValidOpenCodePassword(password: string): boolean {
|
|
return typeof password === 'string' && password.trim().length > 0;
|
|
}
|
|
|
|
function readOpenChamberSettings(): Record<string, unknown> {
|
|
const settingsPath = path.join(os.homedir(), '.config', 'openchamber', 'settings.json');
|
|
try {
|
|
const raw = fs.readFileSync(settingsPath, 'utf8');
|
|
const parsed = JSON.parse(raw) as unknown;
|
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
return parsed as Record<string, unknown>;
|
|
}
|
|
return {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
function resolvePortFromUrl(url: string): number | null {
|
|
try {
|
|
const parsed = new URL(url);
|
|
return parsed.port ? parseInt(parsed.port, 10) : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function isExecutable(filePath: string): boolean {
|
|
if (!filePath) return false;
|
|
try {
|
|
const stat = fs.statSync(filePath);
|
|
if (!stat.isFile()) return false;
|
|
// Windows executability is extension-based.
|
|
if (process.platform === 'win32') {
|
|
const ext = path.extname(filePath).toLowerCase();
|
|
if (!ext) return true;
|
|
return ['.exe', '.cmd', '.bat', '.com'].includes(ext);
|
|
}
|
|
fs.accessSync(filePath, fs.constants.X_OK);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
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;
|
|
const current = process.env.PATH || '';
|
|
const parts = current.split(path.delimiter).filter(Boolean);
|
|
if (parts.includes(trimmed)) return;
|
|
process.env.PATH = [trimmed, ...parts].join(path.delimiter);
|
|
}
|
|
|
|
function findExecutableInPath(binaryName: string): string | null {
|
|
const trimmed = (binaryName || '').trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
|
|
const current = process.env.PATH || '';
|
|
if (!current) {
|
|
return null;
|
|
}
|
|
|
|
const extensions = process.platform === 'win32' ? WINDOWS_EXECUTABLE_EXTENSIONS : [''];
|
|
for (const segment of current.split(path.delimiter)) {
|
|
const dir = segment.trim();
|
|
if (!dir) {
|
|
continue;
|
|
}
|
|
|
|
for (const ext of extensions) {
|
|
const candidate = path.join(dir, process.platform === 'win32' ? `${trimmed}${ext}` : trimmed);
|
|
if (isExecutable(candidate)) {
|
|
return candidate;
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
let cachedDetectedOpencodeCliPath: string | undefined;
|
|
|
|
function normalizeConfiguredOpencodeBinary(raw: unknown): string | null {
|
|
if (typeof raw !== 'string') {
|
|
return null;
|
|
}
|
|
const trimmed = raw.trim();
|
|
if (!trimmed) {
|
|
return null;
|
|
}
|
|
try {
|
|
const stat = fs.statSync(trimmed);
|
|
if (stat.isDirectory()) {
|
|
return path.join(trimmed, process.platform === 'win32' ? 'opencode.exe' : 'opencode');
|
|
}
|
|
} catch {
|
|
// Keep the explicit path so strict startup validation can report it.
|
|
}
|
|
return trimmed;
|
|
}
|
|
|
|
function isMacOpenCodeAppBundlePath(candidate: string): boolean {
|
|
return process.platform === 'darwin' && /\/OpenCode\.app\/Contents\/MacOS\/(?:OpenCode|opencode-cli)$/i.test(candidate);
|
|
}
|
|
|
|
function createConfiguredOpencodeBinaryError(raw: string, normalized: string): Error {
|
|
const messageSuffix = 'OpenChamber needs the standalone opencode CLI. Install it and set openchamber.opencodeBinary to the CLI path, for example ~/.opencode/bin/opencode, or leave the setting empty to use PATH lookup.';
|
|
if (isMacOpenCodeAppBundlePath(raw) || isMacOpenCodeAppBundlePath(normalized)) {
|
|
return new Error(`Configured OpenCode binary points at the macOS desktop app bundle, not the CLI: ${normalized}. ${messageSuffix}`);
|
|
}
|
|
|
|
try {
|
|
const rawStat = fs.statSync(raw);
|
|
if (rawStat.isDirectory()) {
|
|
return new Error(`Configured OpenCode binary directory does not contain an executable ${process.platform === 'win32' ? 'opencode.exe' : 'opencode'}: ${raw}. ${messageSuffix}`);
|
|
}
|
|
} catch {
|
|
// The normalized path check below produces the missing-path error.
|
|
}
|
|
|
|
try {
|
|
const stat = fs.statSync(normalized);
|
|
if (!stat.isFile()) {
|
|
return new Error(`Configured OpenCode binary is not a file: ${normalized}. ${messageSuffix}`);
|
|
}
|
|
return new Error(`Configured OpenCode binary is not executable: ${normalized}. ${messageSuffix}`);
|
|
} catch {
|
|
return new Error(`Configured OpenCode binary not found: ${normalized}. ${messageSuffix}`);
|
|
}
|
|
}
|
|
|
|
function validateConfiguredOpencodeBinaryForManagedStart(): string | null {
|
|
const candidates: string[] = [];
|
|
try {
|
|
const config = vscode.workspace.getConfiguration('openchamber');
|
|
const raw = config.get<string>('opencodeBinary') || '';
|
|
if (raw.trim()) {
|
|
candidates.push(raw.trim());
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
try {
|
|
const settings = readOpenChamberSettings();
|
|
const raw = typeof settings.opencodeBinary === 'string' ? settings.opencodeBinary.trim() : '';
|
|
if (raw) {
|
|
candidates.push(raw);
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
const raw = candidates[0];
|
|
if (!raw) {
|
|
return null;
|
|
}
|
|
|
|
const normalized = normalizeConfiguredOpencodeBinary(raw);
|
|
if (!normalized) {
|
|
return null;
|
|
}
|
|
|
|
if (isExecutable(normalized) && !isMacOpenCodeAppBundlePath(normalized)) {
|
|
return normalized;
|
|
}
|
|
|
|
throw createConfiguredOpencodeBinaryError(raw, normalized);
|
|
}
|
|
|
|
function resolveOpencodeCliPath(): string | null {
|
|
const configured = (() => {
|
|
try {
|
|
const config = vscode.workspace.getConfiguration('openchamber');
|
|
return normalizeConfiguredOpencodeBinary(config.get<string>('opencodeBinary') || '');
|
|
} catch {
|
|
return null;
|
|
}
|
|
})();
|
|
|
|
if (configured && isExecutable(configured) && !isMacOpenCodeAppBundlePath(configured)) {
|
|
return configured;
|
|
}
|
|
|
|
const sharedFromOpenChamber = (() => {
|
|
try {
|
|
const settings = readOpenChamberSettings();
|
|
const candidate = settings.opencodeBinary;
|
|
if (typeof candidate !== 'string') {
|
|
return null;
|
|
}
|
|
return normalizeConfiguredOpencodeBinary(candidate);
|
|
} catch {
|
|
return null;
|
|
}
|
|
})();
|
|
|
|
if (sharedFromOpenChamber && isExecutable(sharedFromOpenChamber) && !isMacOpenCodeAppBundlePath(sharedFromOpenChamber)) {
|
|
return sharedFromOpenChamber;
|
|
}
|
|
|
|
const explicit = [
|
|
process.env.OPENCODE_BINARY,
|
|
process.env.OPENCODE_PATH,
|
|
process.env.OPENCHAMBER_OPENCODE_PATH,
|
|
process.env.OPENCHAMBER_OPENCODE_BIN,
|
|
]
|
|
.map((v) => (typeof v === 'string' ? v.trim() : ''))
|
|
.filter(Boolean);
|
|
|
|
for (const candidate of explicit) {
|
|
if (isExecutable(candidate)) {
|
|
return candidate;
|
|
}
|
|
}
|
|
|
|
if (cachedDetectedOpencodeCliPath) {
|
|
if (isExecutable(cachedDetectedOpencodeCliPath)) {
|
|
return cachedDetectedOpencodeCliPath;
|
|
}
|
|
cachedDetectedOpencodeCliPath = undefined;
|
|
}
|
|
|
|
const home = os.homedir();
|
|
const unixFallbacks = [
|
|
path.join(home, '.opencode', 'bin', 'opencode'),
|
|
path.join(home, '.bun', 'bin', 'opencode'),
|
|
path.join(home, '.local', 'bin', 'opencode'),
|
|
'/usr/local/bin/opencode',
|
|
'/opt/homebrew/bin/opencode',
|
|
path.join(home, 'bin', 'opencode'),
|
|
];
|
|
|
|
const winFallbacks = (() => {
|
|
const userProfile = process.env.USERPROFILE || home;
|
|
const appData = process.env.APPDATA || path.join(userProfile, 'AppData', 'Roaming');
|
|
const localAppData = process.env.LOCALAPPDATA || '';
|
|
const programData = process.env.ProgramData || 'C:\\ProgramData';
|
|
const npmDir = path.join(appData, 'npm');
|
|
|
|
return [
|
|
path.join(userProfile, '.opencode', 'bin', 'opencode.exe'),
|
|
path.join(userProfile, '.opencode', 'bin', 'opencode.cmd'),
|
|
path.join(npmDir, 'node_modules', 'opencode-ai', 'bin', 'opencode.exe'),
|
|
path.join(npmDir, 'opencode.exe'),
|
|
path.join(npmDir, 'opencode.cmd'),
|
|
path.join(npmDir, 'opencode.bat'),
|
|
path.join(userProfile, 'scoop', 'shims', 'opencode.cmd'),
|
|
path.join(programData, 'chocolatey', 'bin', 'opencode.exe'),
|
|
path.join(programData, 'chocolatey', 'bin', 'opencode.cmd'),
|
|
// Bun global install
|
|
path.join(userProfile, '.bun', 'bin', 'opencode.exe'),
|
|
path.join(userProfile, '.bun', 'bin', 'opencode.cmd'),
|
|
// Some installers use LocalAppData
|
|
localAppData ? path.join(localAppData, 'Programs', 'opencode', 'opencode.exe') : '',
|
|
].filter(Boolean);
|
|
})();
|
|
|
|
if (process.platform !== 'win32') {
|
|
const fromPath = findExecutableInPath('opencode');
|
|
if (fromPath) {
|
|
cachedDetectedOpencodeCliPath = fromPath;
|
|
return fromPath;
|
|
}
|
|
}
|
|
|
|
const fallbacks = process.platform === 'win32' ? winFallbacks : unixFallbacks;
|
|
for (const candidate of fallbacks) {
|
|
if (isExecutable(candidate)) {
|
|
cachedDetectedOpencodeCliPath = candidate;
|
|
return candidate;
|
|
}
|
|
}
|
|
|
|
if (process.platform === 'win32') {
|
|
const fromPath = findExecutableInPath('opencode');
|
|
if (fromPath) {
|
|
cachedDetectedOpencodeCliPath = fromPath;
|
|
return fromPath;
|
|
}
|
|
|
|
try {
|
|
const result = spawnSync('where', ['opencode'], {
|
|
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) {
|
|
cachedDetectedOpencodeCliPath = found;
|
|
return found;
|
|
}
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
type ReadyResult =
|
|
| { ok: true; baseUrl: string; elapsedMs: number; attempts: number; version: string | null }
|
|
| { ok: false; elapsedMs: number; attempts: number; version: null };
|
|
|
|
function normalizeBaseUrl(value: string): string {
|
|
return value.replace(/\/+$/, '');
|
|
}
|
|
|
|
function getCandidateBaseUrls(serverUrl: string): string[] {
|
|
const normalized = normalizeBaseUrl(serverUrl);
|
|
try {
|
|
const parsed = new URL(normalized);
|
|
const origin = parsed.origin;
|
|
|
|
const candidates: string[] = [];
|
|
const add = (url: string) => {
|
|
const v = normalizeBaseUrl(url);
|
|
if (!candidates.includes(v)) candidates.push(v);
|
|
};
|
|
|
|
const normalizedPath = parsed.pathname.replace(/\/+$/, '');
|
|
// Prefer plain origin. Only keep SDK url when already root.
|
|
add(origin);
|
|
if (normalizedPath === '' || normalizedPath === '/') {
|
|
add(normalized);
|
|
}
|
|
|
|
return candidates;
|
|
} catch {
|
|
return [normalized];
|
|
}
|
|
}
|
|
|
|
let cachedLoginShellEnvSnapshot: Record<string, string> | null | undefined;
|
|
|
|
function parseNullSeparatedEnvSnapshot(raw: string): Record<string, string> | null {
|
|
if (typeof raw !== 'string' || raw.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const result: Record<string, string> = {};
|
|
const entries = raw.split('\0');
|
|
for (const entry of entries) {
|
|
if (!entry) {
|
|
continue;
|
|
}
|
|
const idx = entry.indexOf('=');
|
|
if (idx <= 0) {
|
|
continue;
|
|
}
|
|
const key = entry.slice(0, idx);
|
|
const value = entry.slice(idx + 1);
|
|
result[key] = value;
|
|
}
|
|
|
|
return Object.keys(result).length > 0 ? result : null;
|
|
}
|
|
|
|
function getWindowsShellEnvSnapshot(): Record<string, string> | null {
|
|
const parseResult = (stdout: string | null | undefined) => parseNullSeparatedEnvSnapshot(typeof stdout === 'string' ? stdout : '');
|
|
|
|
const psScript =
|
|
"Get-ChildItem Env: | ForEach-Object { [Console]::Out.Write($_.Name); [Console]::Out.Write('='); [Console]::Out.Write($_.Value); [Console]::Out.Write([char]0) }";
|
|
|
|
const powershellCandidates = [
|
|
'pwsh.exe',
|
|
'powershell.exe',
|
|
path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'),
|
|
];
|
|
|
|
for (const shellPath of powershellCandidates) {
|
|
try {
|
|
const result = spawnSync(shellPath, ['-NoLogo', '-Command', psScript], {
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
maxBuffer: 10 * 1024 * 1024,
|
|
windowsHide: true,
|
|
});
|
|
if (result.status !== 0) {
|
|
continue;
|
|
}
|
|
const parsed = parseResult(result.stdout);
|
|
if (parsed) {
|
|
return parsed;
|
|
}
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
const comspec = process.env.ComSpec || 'cmd.exe';
|
|
try {
|
|
const result = spawnSync(comspec, ['/d', '/s', '/c', 'set'], {
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
maxBuffer: 10 * 1024 * 1024,
|
|
windowsHide: true,
|
|
});
|
|
if (result.status === 0 && typeof result.stdout === 'string' && result.stdout.length > 0) {
|
|
return parseNullSeparatedEnvSnapshot(result.stdout.replace(/\r?\n/g, '\0'));
|
|
}
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
function getLoginShellEnvSnapshot(): Record<string, string> | null {
|
|
if (cachedLoginShellEnvSnapshot !== undefined) {
|
|
return cachedLoginShellEnvSnapshot;
|
|
}
|
|
|
|
// Avoid interactive POSIX login shells in the extension host.
|
|
if (process.platform !== 'win32') {
|
|
cachedLoginShellEnvSnapshot = null;
|
|
return null;
|
|
}
|
|
|
|
const windowsSnapshot = getWindowsShellEnvSnapshot();
|
|
cachedLoginShellEnvSnapshot = windowsSnapshot;
|
|
return windowsSnapshot;
|
|
}
|
|
|
|
function mergePathValues(preferred: string, fallback: string): string {
|
|
const merged = new Set<string>();
|
|
const addSegments = (value: string) => {
|
|
if (typeof value !== 'string' || !value) {
|
|
return;
|
|
}
|
|
for (const segment of value.split(path.delimiter)) {
|
|
if (segment) {
|
|
merged.add(segment);
|
|
}
|
|
}
|
|
};
|
|
|
|
addSegments(preferred);
|
|
addSegments(fallback);
|
|
return Array.from(merged).join(path.delimiter);
|
|
}
|
|
|
|
function applyLoginShellEnvSnapshot() {
|
|
const snapshot = getLoginShellEnvSnapshot();
|
|
if (!snapshot) {
|
|
return;
|
|
}
|
|
|
|
const skipKeys = new Set(['PWD', 'OLDPWD', 'SHLVL', '_']);
|
|
for (const [key, value] of Object.entries(snapshot)) {
|
|
if (skipKeys.has(key)) {
|
|
continue;
|
|
}
|
|
const existing = process.env[key];
|
|
if (typeof existing === 'string' && existing.length > 0) {
|
|
continue;
|
|
}
|
|
process.env[key] = value;
|
|
}
|
|
|
|
process.env.PATH = mergePathValues(snapshot.PATH || '', process.env.PATH || '');
|
|
}
|
|
|
|
async function waitForReady(
|
|
serverUrl: string,
|
|
timeoutMs = 15000,
|
|
authHeaders: Record<string, string> = {}
|
|
): Promise<ReadyResult> {
|
|
const outputChannel = vscode.window.createOutputChannel('OpenChamberManager');
|
|
const start = Date.now();
|
|
const candidates = getCandidateBaseUrls(serverUrl);
|
|
let attempts = 0;
|
|
|
|
while (Date.now() - start < timeoutMs) {
|
|
for (const baseUrl of candidates) {
|
|
attempts += 1;
|
|
try {
|
|
const controller = new AbortController();
|
|
const timeout = setTimeout(() => controller.abort(), 3000);
|
|
|
|
// OpenCode readiness check.
|
|
const url = new URL(`${baseUrl}/api/health`);
|
|
const res = await fetch(url.toString(), {
|
|
method: 'GET',
|
|
headers: { Accept: 'application/json', ...authHeaders },
|
|
signal: controller.signal,
|
|
});
|
|
|
|
let body: { healthy?: boolean, version?: string } | null = null;
|
|
try {
|
|
body = (await res.json()) as { healthy?: boolean, version?: string };
|
|
} catch {
|
|
body = null;
|
|
}
|
|
|
|
clearTimeout(timeout);
|
|
outputChannel?.appendLine(
|
|
`Health check to ${url.toString()} returned ${res.status} with body: ${JSON.stringify(body)}`
|
|
);
|
|
|
|
if (res.ok && body?.healthy === true) {
|
|
return { ok: true, baseUrl, elapsedMs: Date.now() - start, attempts, version: body?.version ?? null };
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
await new Promise(r => setTimeout(r, 100));
|
|
}
|
|
|
|
return { ok: false, elapsedMs: Date.now() - start, attempts, version: null };
|
|
}
|
|
|
|
async function spawnManagedOpenCodeServer(
|
|
workingDirectory: string,
|
|
port: number,
|
|
timeoutMs: number
|
|
): Promise<{ url: string; close: () => void }> {
|
|
const binary = (process.env.OPENCODE_BINARY || 'opencode').trim() || 'opencode';
|
|
const args = ['serve', '--hostname', '127.0.0.1', '--port', String(port)];
|
|
const child = spawn(binary, args, {
|
|
cwd: workingDirectory,
|
|
env: { ...process.env },
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
windowsHide: true,
|
|
shell: shouldUseWindowsShell(binary),
|
|
});
|
|
|
|
const url = await new Promise<string>((resolve, reject) => {
|
|
let output = '';
|
|
let settled = false;
|
|
|
|
const cleanup = () => {
|
|
if (settled) return;
|
|
settled = true;
|
|
clearTimeout(timer);
|
|
child.stdout?.off('data', onStdout);
|
|
child.stderr?.off('data', onStderr);
|
|
child.off('exit', onExit);
|
|
child.off('error', onError);
|
|
};
|
|
|
|
const onStdout = (chunk: Buffer) => {
|
|
output += chunk.toString();
|
|
const lines = output.split('\n');
|
|
for (const line of lines) {
|
|
if (!line.startsWith('opencode server listening')) continue;
|
|
const match = line.match(/on\s+(https?:\/\/[^\s]+)/);
|
|
if (!match) {
|
|
cleanup();
|
|
reject(new Error(`Failed to parse server url from output: ${line}`));
|
|
return;
|
|
}
|
|
cleanup();
|
|
resolve(match[1]);
|
|
return;
|
|
}
|
|
};
|
|
|
|
const onStderr = (chunk: Buffer) => {
|
|
output += chunk.toString();
|
|
};
|
|
|
|
const onExit = (code: number | null) => {
|
|
cleanup();
|
|
const appBundleHint = isMacOpenCodeAppBundlePath(binary)
|
|
? ' The configured binary appears to point at the macOS desktop app bundle; OpenChamber needs the standalone opencode CLI.'
|
|
: '';
|
|
reject(new Error(`OpenCode process exited before serving with code ${code}. Binary used: ${binary}.${appBundleHint} Output: ${output}`));
|
|
};
|
|
|
|
const onError = (error: Error) => {
|
|
cleanup();
|
|
reject(error);
|
|
};
|
|
|
|
const timer = setTimeout(() => {
|
|
cleanup();
|
|
reject(new Error(`Timeout waiting for server to start after ${timeoutMs}ms`));
|
|
}, timeoutMs);
|
|
|
|
child.stdout?.on('data', onStdout);
|
|
child.stderr?.on('data', onStderr);
|
|
child.on('exit', onExit);
|
|
child.on('error', onError);
|
|
});
|
|
|
|
return {
|
|
url,
|
|
close: () => {
|
|
try {
|
|
child.kill('SIGTERM');
|
|
} catch {
|
|
// ignore
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
async function allocateManagedOpenCodePort(): Promise<number> {
|
|
return await new Promise((resolve, reject) => {
|
|
const server = net.createServer();
|
|
|
|
server.once('error', (error) => {
|
|
reject(error);
|
|
});
|
|
|
|
server.once('listening', () => {
|
|
const address = server.address();
|
|
const port = address && typeof address === 'object' ? address.port : 0;
|
|
server.close(() => {
|
|
if (port > 0) {
|
|
resolve(port);
|
|
return;
|
|
}
|
|
reject(new Error('Failed to allocate OpenCode port'));
|
|
});
|
|
});
|
|
|
|
server.listen(0, '127.0.0.1');
|
|
});
|
|
}
|
|
|
|
export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCodeManager {
|
|
void _context;
|
|
let server: { url: string; close: () => void } | null = null;
|
|
let managedApiUrlOverride: string | null = null;
|
|
let managedPassword: string | null = null;
|
|
let managedPasswordSource: 'user-env' | 'generated' | 'rotated' | null = null;
|
|
const userProvidedEnvPassword = (() => {
|
|
const normalized = (process.env.OPENCODE_SERVER_PASSWORD || '').trim();
|
|
return isValidOpenCodePassword(normalized) ? normalized : null;
|
|
})();
|
|
let status: ConnectionStatus = 'disconnected';
|
|
let lastError: string | undefined;
|
|
const listeners = new Set<(status: ConnectionStatus, error?: string) => void>();
|
|
const workspaceDirectory = (): string =>
|
|
normalizeWindowsDriveLetter(vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || os.homedir());
|
|
let workingDirectory: string = workspaceDirectory();
|
|
let startCount = 0;
|
|
let restartCount = 0;
|
|
let lastStartAt: number | null = null;
|
|
let lastConnectedAt: number | null = null;
|
|
let lastExitCode: number | null = null;
|
|
let lastReadyElapsedMs: number | null = null;
|
|
let lastReadyAttempts: number | null = null;
|
|
let lastStartAttempts: number | null = null;
|
|
let version: string | null = null;
|
|
|
|
let detectedPort: number | null = null;
|
|
let cliMissing = false;
|
|
let cliPath: string | null = null;
|
|
|
|
let pendingOperation: Promise<void> | null = null;
|
|
|
|
const config = vscode.workspace.getConfiguration('openchamber');
|
|
const configuredApiUrl = config.get<string>('apiUrl') || '';
|
|
const useConfiguredUrl = configuredApiUrl && configuredApiUrl.trim().length > 0;
|
|
|
|
let configuredPort: number | null = null;
|
|
if (useConfiguredUrl) {
|
|
try {
|
|
const parsed = new URL(configuredApiUrl);
|
|
if (parsed.port) {
|
|
configuredPort = parseInt(parsed.port, 10);
|
|
}
|
|
} catch {
|
|
// Invalid URL
|
|
}
|
|
}
|
|
|
|
const setStatus = (newStatus: ConnectionStatus, error?: string) => {
|
|
if (status !== newStatus || lastError !== error) {
|
|
status = newStatus;
|
|
lastError = error;
|
|
if (newStatus === 'connected') {
|
|
lastConnectedAt = Date.now();
|
|
}
|
|
listeners.forEach(cb => cb(status, error));
|
|
}
|
|
};
|
|
|
|
const getApiUrl = (): string | null => {
|
|
if (useConfiguredUrl && configuredApiUrl) {
|
|
return configuredApiUrl.replace(/\/+$/, '');
|
|
}
|
|
if (managedApiUrlOverride) {
|
|
return managedApiUrlOverride.replace(/\/+$/, '');
|
|
}
|
|
if (server?.url) {
|
|
return server.url.replace(/\/+$/, '');
|
|
}
|
|
if (detectedPort) {
|
|
return `http://127.0.0.1:${detectedPort}`;
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const getOpenCodeAuthHeaders = (): Record<string, string> => {
|
|
const password = (managedPassword || userProvidedEnvPassword || process.env.OPENCODE_SERVER_PASSWORD || '').trim();
|
|
if (!password) {
|
|
return {};
|
|
}
|
|
return { Authorization: buildOpenCodeAuthHeader(password) };
|
|
};
|
|
|
|
const setManagedPasswordState = (
|
|
password: string,
|
|
source: 'user-env' | 'generated' | 'rotated'
|
|
): string => {
|
|
const normalized = password.trim();
|
|
managedPassword = normalized;
|
|
managedPasswordSource = source;
|
|
process.env.OPENCODE_SERVER_PASSWORD = normalized;
|
|
return normalized;
|
|
};
|
|
|
|
const ensureManagedOpenCodeServerPassword = async ({ rotateManaged = false }: { rotateManaged?: boolean } = {}): Promise<string> => {
|
|
if (userProvidedEnvPassword) {
|
|
return setManagedPasswordState(userProvidedEnvPassword, 'user-env');
|
|
}
|
|
|
|
if (rotateManaged) {
|
|
return setManagedPasswordState(generateSecureOpenCodePassword(), 'rotated');
|
|
}
|
|
|
|
if (managedPassword && isValidOpenCodePassword(managedPassword)) {
|
|
return setManagedPasswordState(
|
|
managedPassword,
|
|
managedPasswordSource || 'generated'
|
|
);
|
|
}
|
|
|
|
return setManagedPasswordState(generateSecureOpenCodePassword(), 'generated');
|
|
};
|
|
|
|
async function startInternal(
|
|
workdir?: string,
|
|
options: { rotateManaged?: boolean } = {}
|
|
): Promise<void> {
|
|
startCount += 1;
|
|
setStatus('connecting');
|
|
lastStartAt = Date.now();
|
|
lastStartAttempts = startCount;
|
|
|
|
if (typeof workdir === 'string' && workdir.trim().length > 0) {
|
|
workingDirectory = normalizeWindowsDriveLetter(workdir.trim());
|
|
} else {
|
|
workingDirectory = workspaceDirectory();
|
|
}
|
|
|
|
if (useConfiguredUrl && configuredApiUrl) {
|
|
setStatus('connecting');
|
|
setStatus('connected');
|
|
return;
|
|
}
|
|
|
|
// If server already running, don't spawn another
|
|
if (server) {
|
|
if (status !== 'connected') {
|
|
setStatus('connected');
|
|
}
|
|
return;
|
|
}
|
|
|
|
setStatus('connecting');
|
|
cliMissing = false;
|
|
cliPath = null;
|
|
|
|
detectedPort = null;
|
|
lastExitCode = null;
|
|
managedApiUrlOverride = null;
|
|
|
|
try {
|
|
applyLoginShellEnvSnapshot();
|
|
|
|
const configuredCli = validateConfiguredOpencodeBinaryForManagedStart();
|
|
if (configuredCli) {
|
|
cliPath = configuredCli;
|
|
appendToPath(path.dirname(configuredCli));
|
|
process.env.OPENCODE_BINARY = configuredCli;
|
|
}
|
|
|
|
// Best-effort: locate CLI even when VS Code PATH is stale.
|
|
const resolvedCli = configuredCli || resolveOpencodeCliPath();
|
|
if (resolvedCli) {
|
|
cliPath = resolvedCli;
|
|
appendToPath(path.dirname(resolvedCli));
|
|
process.env.OPENCODE_BINARY = resolvedCli;
|
|
}
|
|
|
|
const password = await ensureManagedOpenCodeServerPassword({
|
|
rotateManaged: options.rotateManaged === true,
|
|
});
|
|
process.env.OPENCODE_SERVER_PASSWORD = password;
|
|
|
|
// SDK spawns `opencode serve` in current process cwd.
|
|
// Some OpenCode endpoints behave differently based on server process cwd,
|
|
// so ensure we start it from the workspace directory.
|
|
const originalCwd = process.cwd();
|
|
try {
|
|
process.chdir(workingDirectory);
|
|
const port = await allocateManagedOpenCodePort();
|
|
server = await spawnManagedOpenCodeServer(workingDirectory, port, READY_CHECK_TIMEOUT_MS);
|
|
} finally {
|
|
try {
|
|
process.chdir(originalCwd);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
if (server && server.url) {
|
|
// Validate readiness for the current workspace context.
|
|
const ready = await waitForReady(server.url, READY_CHECK_TIMEOUT_MS, getOpenCodeAuthHeaders());
|
|
lastReadyElapsedMs = ready.elapsedMs;
|
|
lastReadyAttempts = ready.attempts;
|
|
if (ready.ok) {
|
|
managedApiUrlOverride = ready.baseUrl;
|
|
detectedPort = resolvePortFromUrl(ready.baseUrl);
|
|
version = ready.version;
|
|
setStatus('connected');
|
|
} else {
|
|
try {
|
|
server.close();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
server = null;
|
|
throw new Error('Server started but health check failed');
|
|
}
|
|
} else {
|
|
throw new Error('Server started but URL is missing');
|
|
}
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
|
|
// Check for ENOENT or generic spawn failure which implies CLI missing
|
|
if (message.includes('ENOENT') || message.includes('spawn opencode')) {
|
|
cliMissing = true;
|
|
if (!cliPath) {
|
|
cliPath = resolveOpencodeCliPath();
|
|
}
|
|
setStatus('error', 'OpenCode CLI not found. Install it and ensure it\'s in PATH.');
|
|
vscode.window.showErrorMessage(
|
|
'OpenCode CLI not found. Please install it and ensure it\'s in PATH.',
|
|
'More Info'
|
|
).then(selection => {
|
|
if (selection === 'More Info') {
|
|
vscode.env.openExternal(vscode.Uri.parse('https://github.com/anomalyco/opencode'));
|
|
}
|
|
});
|
|
} else {
|
|
setStatus('error', `Failed to start OpenCode: ${message}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function stopInternal(): Promise<void> {
|
|
const portToKill = detectedPort;
|
|
|
|
if (server) {
|
|
try {
|
|
server.close();
|
|
} catch {
|
|
// Ignore close errors
|
|
}
|
|
server = null;
|
|
}
|
|
|
|
// Kill any process listening on our port to clean up orphaned children.
|
|
if (portToKill) {
|
|
try {
|
|
const lsofOutput = execSync(`lsof -ti:${portToKill} 2>/dev/null || true`, {
|
|
encoding: 'utf8',
|
|
timeout: 5000
|
|
});
|
|
const myPid = process.pid;
|
|
for (const pidStr of lsofOutput.split(/\s+/)) {
|
|
const pid = parseInt(pidStr.trim(), 10);
|
|
if (pid && pid !== myPid) {
|
|
try {
|
|
execSync(`kill -9 ${pid} 2>/dev/null || true`, { stdio: 'ignore', timeout: 2000 });
|
|
} catch {
|
|
// Ignore
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// Ignore - process may already be dead
|
|
}
|
|
}
|
|
|
|
managedApiUrlOverride = null;
|
|
detectedPort = null;
|
|
version = null;
|
|
setStatus('disconnected');
|
|
}
|
|
|
|
async function restartInternal(): Promise<void> {
|
|
restartCount += 1;
|
|
await stopInternal();
|
|
await new Promise(r => setTimeout(r, 250));
|
|
await startInternal(undefined, { rotateManaged: true });
|
|
}
|
|
|
|
async function start(workdir?: string): Promise<void> {
|
|
if (pendingOperation) {
|
|
await pendingOperation;
|
|
if (server) {
|
|
return;
|
|
}
|
|
}
|
|
lastStartAttempts = 1;
|
|
pendingOperation = startInternal(workdir, { rotateManaged: true });
|
|
try {
|
|
await pendingOperation;
|
|
} finally {
|
|
pendingOperation = null;
|
|
}
|
|
}
|
|
|
|
async function stop(): Promise<void> {
|
|
if (pendingOperation) {
|
|
await pendingOperation;
|
|
}
|
|
// Check if already stopped
|
|
if (!server) {
|
|
return;
|
|
}
|
|
pendingOperation = stopInternal();
|
|
try {
|
|
await pendingOperation;
|
|
} finally {
|
|
pendingOperation = null;
|
|
}
|
|
}
|
|
|
|
async function restart(): Promise<void> {
|
|
if (pendingOperation) {
|
|
await pendingOperation;
|
|
}
|
|
lastStartAttempts = 1;
|
|
pendingOperation = restartInternal();
|
|
try {
|
|
await pendingOperation;
|
|
} finally {
|
|
pendingOperation = null;
|
|
}
|
|
}
|
|
|
|
async function setWorkingDirectory(newPath: string): Promise<{ success: boolean; restarted: boolean; path: string }> {
|
|
void newPath;
|
|
const workspacePath = workspaceDirectory();
|
|
const nextDirectory = workspacePath;
|
|
|
|
if (workingDirectory === nextDirectory) {
|
|
return { success: true, restarted: false, path: nextDirectory };
|
|
}
|
|
|
|
workingDirectory = nextDirectory;
|
|
|
|
if (useConfiguredUrl && configuredApiUrl) {
|
|
return { success: true, restarted: false, path: nextDirectory };
|
|
}
|
|
|
|
return { success: true, restarted: false, path: nextDirectory };
|
|
}
|
|
|
|
return {
|
|
start,
|
|
stop,
|
|
restart,
|
|
setWorkingDirectory,
|
|
getStatus: () => status,
|
|
getApiUrl,
|
|
getOpenCodeAuthHeaders,
|
|
getWorkingDirectory: () => workingDirectory,
|
|
isCliAvailable: () => !cliMissing || Boolean(cliPath || resolveOpencodeCliPath()),
|
|
getDebugInfo: () => {
|
|
const secureConnection = Boolean(getOpenCodeAuthHeaders().Authorization);
|
|
const detectedCliPath = cliPath || resolveOpencodeCliPath();
|
|
return {
|
|
mode: useConfiguredUrl && configuredApiUrl ? 'external' : 'managed',
|
|
status,
|
|
lastError,
|
|
workingDirectory,
|
|
cliAvailable: !cliMissing || Boolean(detectedCliPath),
|
|
cliPath: detectedCliPath,
|
|
configuredApiUrl: useConfiguredUrl && configuredApiUrl ? configuredApiUrl.replace(/\/+$/, '') : null,
|
|
configuredPort,
|
|
detectedPort,
|
|
apiPrefix: '',
|
|
apiPrefixDetected: true,
|
|
startCount,
|
|
restartCount,
|
|
lastStartAt,
|
|
lastConnectedAt,
|
|
lastExitCode,
|
|
serverUrl: getApiUrl(),
|
|
lastReadyElapsedMs,
|
|
lastReadyAttempts,
|
|
lastStartAttempts,
|
|
version,
|
|
secureConnection,
|
|
authSource: managedPasswordSource || (userProvidedEnvPassword ? 'user-env' : null),
|
|
};
|
|
},
|
|
onStatusChange(callback) {
|
|
listeners.add(callback);
|
|
callback(status, lastError);
|
|
return new vscode.Disposable(() => listeners.delete(callback));
|
|
},
|
|
};
|
|
}
|