From 6f32a206b45c25546ec60d76ab068445cb466a70 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Mon, 11 May 2026 14:28:12 +0000 Subject: [PATCH] Support remote project tabs with local OpenCode bridge Keeps provider credentials local while executing OpenCode shell and file tools against the selected remote workspace over SSH. --- applications/electron-shell/package.json | 1 + applications/electron-shell/preload.cjs | 2 + applications/electron-shell/ts/main.ts | 1302 ++++++++++++++--- applications/electron-shell/ts/plugins.ts | 4 +- applications/remote-theia/package.json | 2 + .../remote-theia/src-gen/backend/main.js | 2 +- .../remote-theia/src-gen/frontend/index.js | 5 +- packages/opencode-bridge/ts/index.ts | 149 ++ packages/server-installer/ts/index.ts | 345 ++++- packages/ssh/ts/index.ts | 10 +- pnpm-lock.yaml | 3 + test/test.installer.node.ts | 95 ++ test/test.opencode-bridge.node.ts | 39 + test/test.theia-config.node.ts | 2 + .../gitzone-opencode-node-service.d.ts.map | 2 +- .../lib/node/gitzone-opencode-node-service.js | 11 +- .../node/gitzone-opencode-node-service.js.map | 2 +- .../src/node/gitzone-opencode-node-service.ts | 11 +- 18 files changed, 1793 insertions(+), 194 deletions(-) create mode 100644 test/test.opencode-bridge.node.ts diff --git a/applications/electron-shell/package.json b/applications/electron-shell/package.json index 16f6fa3..7efce9d 100644 --- a/applications/electron-shell/package.json +++ b/applications/electron-shell/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@git.zone/ide-protocol": "workspace:*", + "@git.zone/ide-opencode-bridge": "workspace:*", "@git.zone/ide-server-installer": "workspace:*", "@git.zone/ide-ssh": "workspace:*", "electron": "^42.0.1" diff --git a/applications/electron-shell/preload.cjs b/applications/electron-shell/preload.cjs index 4bb9cb9..2989459 100644 --- a/applications/electron-shell/preload.cjs +++ b/applications/electron-shell/preload.cjs @@ -4,6 +4,8 @@ contextBridge.exposeInMainWorld('gitZoneIde', { listHosts: () => ipcRenderer.invoke('gitzone:list-hosts'), saveHost: (input) => ipcRenderer.invoke('gitzone:save-host', input), connect: (input) => ipcRenderer.invoke('gitzone:connect', input), + addProject: (input) => ipcRenderer.invoke('gitzone:add-project', input), + openProject: (input) => ipcRenderer.invoke('gitzone:open-project', input), onConnectProgress: (callback) => { const listener = (_event, message) => callback(message); ipcRenderer.on('gitzone:connect-progress', listener); diff --git a/applications/electron-shell/ts/main.ts b/applications/electron-shell/ts/main.ts index 221efda..44b5a13 100644 --- a/applications/electron-shell/ts/main.ts +++ b/applications/electron-shell/ts/main.ts @@ -15,7 +15,10 @@ const workspaceRoot = path.resolve(electronPackageRoot, '../..'); const runtimeMarkerFileName = plugins.ideServerInstaller.remoteEphemeralRuntimeMarkerFileName; class GitZoneIdeElectronShell { + private readonly hostSessions = new Map(); private readonly tunnels: plugins.ideSsh.ISshTunnelHandle[] = []; + private readonly localOpenCodeRuntimes = new Map(); + private toolBridge: LocalOpenCodeToolBridge | undefined; async start() { await plugins.electron.app.whenReady(); @@ -38,6 +41,10 @@ class GitZoneIdeElectronShell { for (const tunnel of this.tunnels) { void tunnel.dispose(); } + for (const runtime of this.localOpenCodeRuntimes.values()) { + disposeLocalOpenCodeRuntime(runtime); + } + void this.toolBridge?.dispose(); }); } @@ -65,102 +72,245 @@ class GitZoneIdeElectronShell { plugins.electron.ipcMain.handle('gitzone:connect', async (event, input: IConnectInput) => { const progress = createProgressEmitter(event.sender); - progress('Allocating local tunnel port.'); - const localPort = await plugins.ideSsh.findFreePort(); const target = createSshTarget(input); progress(`Preparing SSH connection to ${target.hostAlias}.`); - const opencodePassword = plugins.crypto.randomBytes(24).toString('base64url'); - const remoteTheiaPort = normalizeOptionalPort(input.remoteTheiaPort, 'Remote Theia Port') ?? defaultRemoteTheiaPort; - const opencodePort = normalizeOptionalPort(input.openCodePort, 'OpenCode Port') ?? defaultOpenCodePort; const serverVersion = plugins.electron.app.getVersion(); progress('Staging remote runtime payload.'); const runtime = await createLocalEphemeralRuntime(serverVersion); progress(`Runtime hash ${runtime.contentHash.slice(0, 12)} staged: ${runtime.fileCount} files, ${formatBytes(runtime.totalBytes)} unpacked.`); try { - progress(`Checking remote /tmp cache at ${runtime.remoteRoot}.`); - const cacheCheckCommand = plugins.ideServerInstaller.createRemoteEphemeralRuntimeCacheCheckCommand({ - runtimeRoot: runtime.remoteRoot, - runtimeSha256: runtime.contentHash, - }); - const cacheCheckResult = await plugins.ideSsh.runSshCommand(target, cacheCheckCommand, { - timeoutMs: 30000, + await uploadRuntimeIfNeeded(target, runtime, input.batchMode ?? true, progress); + progress('Loading remote project registry.'); + const projects = await loadRemoteProjects(target, runtime.remoteRoot, input.batchMode ?? true); + const session: IRemoteHostSession = { + id: plugins.crypto.randomBytes(12).toString('hex'), + target, batchMode: input.batchMode ?? true, - }); - if (cacheCheckResult.exitCode === 0) { - progress(`Remote runtime hash matches; skipping copy for ${runtime.contentHash.slice(0, 12)}.`); - } else { - progress(`Remote runtime cache miss; copying ${formatBytes(runtime.totalBytes)} to ${runtime.remoteRoot}.`); - const reportUploadProgress = createUploadProgressReporter(progress, runtime.totalBytes); - const uploadResult = await plugins.ideSsh.uploadDirectoryToRemote(target, runtime.localRoot, runtime.remoteRoot, { - timeoutMs: 300000, - batchMode: input.batchMode ?? true, - onProgress: (uploadProgress) => reportUploadProgress(uploadProgress.bytesUploaded), - }); - if (uploadResult.exitCode !== 0) { - throw new Error(uploadResult.stderr || `Remote runtime upload failed with ${uploadResult.exitCode}`); - } - progress('Remote runtime files copied; writing cache marker.'); - const markCommand = plugins.ideServerInstaller.createRemoteEphemeralRuntimeMarkCommand({ - runtimeRoot: runtime.remoteRoot, - runtimeSha256: runtime.contentHash, - }); - const markResult = await plugins.ideSsh.runSshCommand(target, markCommand, { - timeoutMs: 30000, - batchMode: input.batchMode ?? true, - }); - if (markResult.exitCode !== 0) { - throw new Error(markResult.stderr || `Remote runtime marker write failed with ${markResult.exitCode}`); - } - progress(`Remote runtime upload complete; cache marker ${runtime.contentHash.slice(0, 12)} stored.`); - } - - progress('Starting remote Theia runtime.'); - const bootstrapCommand = plugins.ideServerInstaller.createRemoteEphemeralBootstrapCommand({ serverVersion, runtimeRoot: runtime.remoteRoot, - workspacePath: input.workspacePath, - theiaPort: remoteTheiaPort, - opencodePort, - opencodeUsername: 'opencode', - opencodePassword, - }); - - const bootstrapResult = await plugins.ideSsh.runSshCommand(target, bootstrapCommand, { - timeoutMs: 30000, - batchMode: input.batchMode ?? true, - }); - if (bootstrapResult.exitCode !== 0) { - throw new Error(bootstrapResult.stderr || `Remote bootstrap failed with ${bootstrapResult.exitCode}`); - } - progress('Waiting for remote Theia readiness.'); - const readinessCommand = plugins.ideServerInstaller.createRemoteEphemeralReadinessCommand({ - runtimeRoot: runtime.remoteRoot, - theiaPort: remoteTheiaPort, - }); - const readinessResult = await plugins.ideSsh.runSshCommand(target, readinessCommand, { - timeoutMs: 45000, - batchMode: input.batchMode ?? true, - }); - if (readinessResult.exitCode !== 0) { - throw new Error(readinessResult.stderr || `Remote Theia readiness check failed with ${readinessResult.exitCode}`); - } - - progress('Opening local SSH tunnel.'); - const tunnel = plugins.ideSsh.startSshTunnel(target, { - localPort, - remotePort: remoteTheiaPort, - batchMode: input.batchMode ?? true, - }); - this.tunnels.push(tunnel); - const url = `http://127.0.0.1:${localPort}`; - progress(`Waiting for local tunnel ${url}.`); - await waitForHttpUrl(url, 15000); - progress('Tunnel ready; opening workspace.'); - return { url, localPort, remoteTheiaPort, opencodePort }; + runtimeHash: runtime.contentHash, + projects, + instances: new Map(), + }; + this.hostSessions.set(session.id, session); + progress(`Connected to ${target.hostAlias}; ${projects.length} project${projects.length === 1 ? '' : 's'} registered.`); + return toHostSessionDescriptor(session); } finally { await fs.rm(runtime.localRoot, { recursive: true, force: true }); } }); + + plugins.electron.ipcMain.handle('gitzone:add-project', async (event, input: IAddProjectInput) => { + const progress = createProgressEmitter(event.sender); + const session = requireHostSession(this.hostSessions, input.connectionId); + const projectPath = requireTrimmed(input.path, 'Project path'); + progress(`Adding remote project ${projectPath}.`); + const upsertCommand = plugins.ideServerInstaller.createRemoteProjectUpsertCommand({ + runtimeRoot: session.runtimeRoot, + projectPath, + title: trimOptional(input.title), + }); + const upsertResult = await plugins.ideSsh.runSshCommand(session.target, upsertCommand, { + timeoutMs: 30000, + batchMode: session.batchMode, + }); + if (upsertResult.exitCode !== 0) { + throw new Error(upsertResult.stderr || `Remote project add failed with ${upsertResult.exitCode}`); + } + session.projects = parseRemoteProjectRegistry(upsertResult.stdout); + progress(`Remote project registry now has ${session.projects.length} project${session.projects.length === 1 ? '' : 's'}.`); + return { projects: session.projects }; + }); + + plugins.electron.ipcMain.handle('gitzone:open-project', async (event, input: IOpenProjectInput) => { + const progress = createProgressEmitter(event.sender); + const session = requireHostSession(this.hostSessions, input.connectionId); + const project = findProject(session, input); + const existingInstance = session.instances.get(project.id); + if (existingInstance) { + progress(`Switching to running project ${project.title}.`); + return existingInstance; + } + + progress(`Allocating remote Theia port for ${project.title}.`); + const portResult = await plugins.ideSsh.runSshCommand( + session.target, + plugins.ideServerInstaller.createRemoteEphemeralPortAllocationCommand({ + runtimeRoot: session.runtimeRoot, + count: 1, + }), + { + timeoutMs: 30000, + batchMode: session.batchMode, + }, + ); + if (portResult.exitCode !== 0) { + throw new Error(portResult.stderr || `Remote port allocation failed with ${portResult.exitCode}`); + } + const [remoteTheiaPort] = parseAllocatedPorts(portResult.stdout, 1); + const localPort = await plugins.ideSsh.findFreePort(); + + progress(`Starting Theia for ${project.title} on remote port ${remoteTheiaPort}.`); + const bootstrapResult = await plugins.ideSsh.runSshCommand( + session.target, + plugins.ideServerInstaller.createRemoteEphemeralBootstrapCommand({ + serverVersion: session.serverVersion, + runtimeRoot: session.runtimeRoot, + workspacePath: project.path, + theiaPort: remoteTheiaPort, + opencodePort: defaultOpenCodePort, + opencodeUsername: 'opencode', + opencodePassword: '', + theiaColorTheme: 'dark', + }), + { + timeoutMs: 30000, + batchMode: session.batchMode, + }, + ); + if (bootstrapResult.exitCode !== 0) { + throw new Error(bootstrapResult.stderr || `Remote bootstrap failed with ${bootstrapResult.exitCode}`); + } + + progress(`Waiting for Theia readiness for ${project.title}.`); + const readinessResult = await plugins.ideSsh.runSshCommand( + session.target, + plugins.ideServerInstaller.createRemoteEphemeralReadinessCommand({ + runtimeRoot: session.runtimeRoot, + theiaPort: remoteTheiaPort, + }), + { + timeoutMs: 45000, + batchMode: session.batchMode, + }, + ); + if (readinessResult.exitCode !== 0) { + throw new Error(readinessResult.stderr || `Remote Theia readiness check failed with ${readinessResult.exitCode}`); + } + + progress(`Opening local tunnel for ${project.title}.`); + const tunnel = plugins.ideSsh.startSshTunnel(session.target, { + localPort, + remotePort: remoteTheiaPort, + batchMode: session.batchMode, + }); + this.tunnels.push(tunnel); + const url = `http://127.0.0.1:${localPort}`; + await waitForHttpUrl(url, 15000); + const instanceId = `${session.id}:${project.id}`; + const openCode = await this.startLocalOpenCodeForProject(session, project, instanceId, progress); + const instance: IProjectInstance = { + id: instanceId, + projectId: project.id, + title: project.title, + path: project.path, + url, + localPort, + remoteTheiaPort, + openCode, + }; + session.instances.set(project.id, instance); + progress(`Project ${project.title} is ready.`); + return instance; + }); + } + + private async ensureToolBridge() { + if (!this.toolBridge) { + this.toolBridge = new LocalOpenCodeToolBridge(); + await this.toolBridge.start(); + } + return this.toolBridge; + } + + private async startLocalOpenCodeForProject( + session: IRemoteHostSession, + project: IRemoteProject, + instanceId: string, + progress: (message: string) => void, + ): Promise { + const bridge = await this.ensureToolBridge(); + const proxyRoot = path.join(plugins.electron.app.getPath('userData'), 'opencode', sanitizeRuntimePart(instanceId)); + const proxyWorkspacePath = path.join(proxyRoot, 'workspace'); + const configDir = path.join(proxyRoot, 'config'); + const bridgeToken = plugins.crypto.randomBytes(32).toString('base64url'); + const port = await plugins.ideSsh.findFreePort(); + const username = 'opencode'; + const password = plugins.crypto.randomBytes(24).toString('base64url'); + const baseUrl = `http://127.0.0.1:${port}`; + + try { + progress(`Starting local OpenCode server for ${project.title}.`); + await fs.rm(proxyRoot, { recursive: true, force: true }); + await fs.mkdir(proxyWorkspacePath, { recursive: true }); + await writeOpenCodeBridgeConfig(configDir); + bridge.register(bridgeToken, { + session, + project, + proxyWorkspacePath, + }); + + const openCodeExecutable = await resolveOpenCodeExecutable(); + const openCodeProcess = childProcess.spawn( + openCodeExecutable, + ['serve', '--hostname', '127.0.0.1', '--port', `${port}`], + { + cwd: proxyWorkspacePath, + env: { + ...process.env, + OPENCODE_CONFIG_DIR: configDir, + OPENCODE_SERVER_USERNAME: username, + OPENCODE_SERVER_PASSWORD: password, + GITZONE_IDE_TOOL_BRIDGE_URL: bridge.baseUrl, + GITZONE_IDE_TOOL_BRIDGE_TOKEN: bridgeToken, + }, + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }, + ); + const runtime: ILocalOpenCodeRuntime = { + instanceId, + process: openCodeProcess, + bridge, + bridgeToken, + configDir, + proxyWorkspacePath, + baseUrl, + username, + password, + }; + this.localOpenCodeRuntimes.set(instanceId, runtime); + openCodeProcess.stdout?.on('data', (chunk: Buffer) => console.log(`[opencode:${project.title}] ${chunk.toString('utf8').trim()}`)); + openCodeProcess.stderr?.on('data', (chunk: Buffer) => console.warn(`[opencode:${project.title}] ${chunk.toString('utf8').trim()}`)); + openCodeProcess.once('error', (error) => { + console.warn(`Local OpenCode server failed for ${project.title}: ${error.message}`); + }); + openCodeProcess.once('exit', () => { + bridge.unregister(bridgeToken); + this.localOpenCodeRuntimes.delete(instanceId); + }); + + await waitForOpenCodeHealth(baseUrl, username, password, 15000); + progress(`Local OpenCode server ready for ${project.title}; remote tools are bridged over SSH.`); + return { + status: 'ready', + message: 'Local OpenCode server is running with remote SSH tool overrides.', + }; + } catch (error) { + bridge.unregister(bridgeToken); + const message = error instanceof Error ? error.message : String(error); + progress(`Local OpenCode server unavailable for ${project.title}: ${message}`); + const runtime = this.localOpenCodeRuntimes.get(instanceId); + if (runtime) { + disposeLocalOpenCodeRuntime(runtime); + this.localOpenCodeRuntimes.delete(instanceId); + } + return { + status: 'unavailable', + message, + }; + } } private async openLauncherWindow() { @@ -172,6 +322,7 @@ class GitZoneIdeElectronShell { contextIsolation: true, nodeIntegration: false, preload: fileURLToPath(new URL('../preload.cjs', import.meta.url)), + webviewTag: true, }, }); window.webContents.on('console-message', (_event, _level, message) => { @@ -216,12 +367,337 @@ interface ISaveHostInput { interface IConnectInput extends ISaveHostInput { hostAlias?: string; - workspacePath: string; + workspacePath?: string; remoteTheiaPort?: number; openCodePort?: number; batchMode?: boolean; } +interface IAddProjectInput { + connectionId?: string; + path?: string; + title?: string; +} + +interface IOpenProjectInput { + connectionId?: string; + projectId?: string; + projectPath?: string; +} + +interface IRemoteProject { + id: string; + path: string; + title: string; + createdAt?: string; + updatedAt?: string; +} + +interface IRemoteHostSession { + id: string; + target: IIdeSshTarget; + batchMode: boolean; + serverVersion: string; + runtimeRoot: string; + runtimeHash: string; + projects: IRemoteProject[]; + instances: Map; +} + +interface IProjectInstance { + id: string; + projectId: string; + title: string; + path: string; + url: string; + localPort: number; + remoteTheiaPort: number; + openCode: IOpenCodeProjectStatus; +} + +interface IOpenCodeProjectStatus { + status: 'ready' | 'unavailable'; + message: string; +} + +interface ILocalOpenCodeRuntime { + instanceId: string; + process: childProcess.ChildProcess; + bridge: LocalOpenCodeToolBridge; + bridgeToken: string; + configDir: string; + proxyWorkspacePath: string; + baseUrl: string; + username: string; + password: string; +} + +interface IOpenCodeToolBridgeContext { + session: IRemoteHostSession; + project: IRemoteProject; + proxyWorkspacePath: string; +} + +class LocalOpenCodeToolBridge { + private readonly contexts = new Map(); + private server: ReturnType | undefined; + baseUrl = ''; + + async start() { + if (this.server) { + return; + } + + this.server = plugins.http.createServer((request, response) => { + void this.handleRequest(request, response); + }); + await new Promise((resolve, reject) => { + this.server!.once('error', reject); + this.server!.listen(0, '127.0.0.1', () => resolve()); + }); + const address = this.server.address(); + if (!address || typeof address !== 'object') { + throw new Error('Unable to bind local OpenCode tool bridge.'); + } + this.baseUrl = `http://127.0.0.1:${address.port}`; + } + + register(token: string, context: IOpenCodeToolBridgeContext) { + this.contexts.set(token, context); + } + + unregister(token: string) { + this.contexts.delete(token); + } + + async dispose() { + this.contexts.clear(); + if (!this.server) { + return; + } + const server = this.server; + this.server = undefined; + await new Promise((resolve) => server.close(() => resolve())); + } + + private async handleRequest(request: any, response: any) { + try { + if (request.method !== 'POST') { + writeJsonResponse(response, 405, { error: 'Method not allowed' }); + return; + } + const url = new URL(request.url ?? '/', this.baseUrl || 'http://127.0.0.1'); + const toolName = decodeURIComponent(url.pathname.replace(/^\/tool\//, '')); + if (!url.pathname.startsWith('/tool/') || !toolName) { + writeJsonResponse(response, 404, { error: 'Tool route not found' }); + return; + } + const authorization = request.headers.authorization ?? ''; + const token = authorization.startsWith('Bearer ') ? authorization.slice('Bearer '.length) : ''; + const context = this.contexts.get(token); + if (!context) { + writeJsonResponse(response, 401, { error: 'Unauthorized' }); + return; + } + const bodyText = await readRequestBody(request, 10 * 1024 * 1024); + const body = bodyText ? JSON.parse(bodyText) as { args?: Record } : {}; + const result = await executeRemoteOpenCodeTool(toolName, context, body.args ?? {}); + writeJsonResponse(response, 200, result); + } catch (error) { + writeJsonResponse(response, 500, { + error: error instanceof Error ? error.message : String(error), + }); + } + } +} + +const readRequestBody = async (request: NodeJS.ReadableStream, maxBytes: number) => { + const chunks: Buffer[] = []; + let totalBytes = 0; + for await (const chunk of request) { + const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk)); + totalBytes += buffer.length; + if (totalBytes > maxBytes) { + throw new Error(`Request body exceeds ${maxBytes} bytes.`); + } + chunks.push(buffer); + } + return Buffer.concat(chunks).toString('utf8'); +}; + +const writeJsonResponse = (response: { statusCode: number; setHeader(name: string, value: string): void; end(body: string): void }, statusCode: number, body: unknown) => { + response.statusCode = statusCode; + response.setHeader('content-type', 'application/json'); + response.end(JSON.stringify(body)); +}; + +const executeRemoteOpenCodeTool = async ( + toolName: string, + context: IOpenCodeToolBridgeContext, + args: Record, +) => { + const normalizedArgs = normalizeOpenCodeToolArgs(toolName, context, args); + const timeoutMs = Math.max(30000, Number(args.timeout || 120000) + 5000); + const result = await plugins.ideSsh.runSshCommand( + context.session.target, + plugins.ideServerInstaller.createRemoteOpenCodeToolCommand({ + runtimeRoot: context.session.runtimeRoot, + workspacePath: context.project.path, + toolName, + }), + { + timeoutMs, + batchMode: context.session.batchMode, + stdin: JSON.stringify({ args: normalizedArgs }), + }, + ); + if (result.exitCode !== 0) { + throw new Error(result.stderr || `Remote OpenCode tool ${toolName} failed with ${result.exitCode}`); + } + try { + return JSON.parse(result.stdout || '""') as unknown; + } catch (error) { + throw new Error(`Remote OpenCode tool ${toolName} returned invalid JSON: ${error instanceof Error ? error.message : String(error)}\n${result.stdout}`); + } +}; + +const normalizeOpenCodeToolArgs = ( + toolName: string, + context: IOpenCodeToolBridgeContext, + args: Record, +) => { + const next: Record = { ...args }; + if (toolName === 'bash') { + if (typeof next.workdir === 'string') { + next.workdir = mapOpenCodeRemotePath(next.workdir, context); + } + return next; + } + if (toolName === 'read' || toolName === 'write' || toolName === 'edit') { + if (typeof next.filePath === 'string') { + next.filePath = mapOpenCodeRemotePath(next.filePath, context); + } + return next; + } + if (toolName === 'grep' || toolName === 'glob') { + if (typeof next.path === 'string') { + next.path = mapOpenCodeRemotePath(next.path, context); + } + return next; + } + if (toolName === 'apply_patch' && typeof next.patchText === 'string') { + next.patchText = normalizeOpenCodePatchText(next.patchText, context); + } + return next; +}; + +const normalizeOpenCodePatchText = (patchText: string, context: IOpenCodeToolBridgeContext) => patchText + .replace(/^(\*\*\* (?:Add|Update|Delete) File: )(.*)$/gm, (_match, prefix: string, filePath: string) => `${prefix}${mapOpenCodePatchPath(filePath, context)}`) + .replace(/^(\*\*\* Move to: )(.*)$/gm, (_match, prefix: string, filePath: string) => `${prefix}${mapOpenCodePatchPath(filePath, context)}`); + +const mapOpenCodePatchPath = (filePath: string, context: IOpenCodeToolBridgeContext) => { + const trimmedPath = filePath.trim(); + if (!path.isAbsolute(trimmedPath)) { + return trimmedPath; + } + const proxyWorkspacePath = path.resolve(context.proxyWorkspacePath); + const normalizedPath = path.resolve(trimmedPath); + if (normalizedPath === proxyWorkspacePath) { + return '.'; + } + if (normalizedPath.startsWith(`${proxyWorkspacePath}${path.sep}`)) { + return path.relative(proxyWorkspacePath, normalizedPath).split(path.sep).join('/'); + } + return trimmedPath; +}; + +const mapOpenCodeRemotePath = (filePath: string, context: IOpenCodeToolBridgeContext) => { + const trimmedPath = filePath.trim(); + if (!trimmedPath) { + return context.project.path; + } + if (trimmedPath === '~' || trimmedPath.startsWith('~/') || trimmedPath === '$HOME' || trimmedPath.startsWith('$HOME/')) { + return trimmedPath; + } + const proxyWorkspacePath = path.resolve(context.proxyWorkspacePath); + if (path.isAbsolute(trimmedPath)) { + const normalizedPath = path.resolve(trimmedPath); + if (normalizedPath === proxyWorkspacePath) { + return context.project.path; + } + if (normalizedPath.startsWith(`${proxyWorkspacePath}${path.sep}`)) { + const relativePath = path.relative(proxyWorkspacePath, normalizedPath).split(path.sep).join('/'); + return plugins.ideServerInstaller.joinRemotePath(context.project.path, relativePath); + } + return trimmedPath; + } + return plugins.ideServerInstaller.joinRemotePath(context.project.path, trimmedPath); +}; + +const writeOpenCodeBridgeConfig = async (configDir: string) => { + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + path.join(configDir, 'opencode.json'), + plugins.ideOpenCodeBridge.renderOpenCodeBridgeConfigContent(), + ); + const toolFiles = plugins.ideOpenCodeBridge.renderOpenCodeBridgeToolFiles(); + for (const [relativePath, content] of Object.entries(toolFiles)) { + const filePath = path.join(configDir, relativePath); + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, content); + } +}; + +const resolveOpenCodeExecutable = async () => { + const candidates = [ + process.env.GITZONE_IDE_OPENCODE_BINARY, + process.env.OPENCODE_BINARY, + path.join(os.homedir(), '.opencode', 'bin', 'opencode'), + '/usr/local/bin/opencode', + '/usr/bin/opencode', + ].filter(Boolean) as string[]; + for (const candidate of candidates) { + try { + await fs.access(candidate, fsConstants.X_OK); + return candidate; + } catch {} + } + return 'opencode'; +}; + +const waitForOpenCodeHealth = async (baseUrl: string, username: string, password: string, timeoutMs: number) => { + const authorization = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; + const startedAt = Date.now(); + let lastError: unknown; + while (Date.now() - startedAt < timeoutMs) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 1000); + try { + const response = await fetch(`${baseUrl}/global/health`, { + headers: { authorization }, + signal: controller.signal, + }); + if (response.ok) { + return; + } + lastError = new Error(`HTTP ${response.status}`); + } catch (error) { + lastError = error; + } finally { + clearTimeout(timer); + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + throw new Error(`Local OpenCode server did not become ready at ${baseUrl}: ${lastError instanceof Error ? lastError.message : String(lastError)}`); +}; + +const disposeLocalOpenCodeRuntime = (runtime: ILocalOpenCodeRuntime) => { + runtime.bridge.unregister(runtime.bridgeToken); + if (runtime.process.exitCode === null && !runtime.process.killed) { + runtime.process.kill('SIGTERM'); + } +}; + const toLauncherHost = (host: plugins.ideSsh.ISshHostConfig): ILauncherHost => ({ alias: host.alias, hostName: host.hostName, @@ -244,7 +720,7 @@ const createSshTarget = (input: IConnectInput): IIdeSshTarget => { hostName: trimOptional(input.hostName), user: trimOptional(input.user), port: normalizeOptionalPort(input.port, 'Port'), - workspacePath: requireTrimmed(input.workspacePath, 'Workspace path'), + workspacePath: trimOptional(input.workspacePath), }; }; @@ -272,6 +748,139 @@ const normalizeOptionalPort = (value: number | undefined, label: string) => { return port; }; +const toHostSessionDescriptor = (session: IRemoteHostSession) => ({ + id: session.id, + hostAlias: session.target.hostAlias, + runtimeRoot: session.runtimeRoot, + runtimeHash: session.runtimeHash, + projects: session.projects, + openProjects: [...session.instances.values()], +}); + +const requireHostSession = (sessions: Map, connectionId: string | undefined) => { + const sessionId = requireTrimmed(connectionId, 'Connection id'); + const session = sessions.get(sessionId); + if (!session) { + throw new Error(`Remote host session not found: ${sessionId}`); + } + return session; +}; + +const findProject = (session: IRemoteHostSession, input: IOpenProjectInput) => { + const project = input.projectId + ? session.projects.find((candidate) => candidate.id === input.projectId) + : session.projects.find((candidate) => candidate.path === input.projectPath); + if (!project) { + throw new Error('Project not found in remote registry.'); + } + return project; +}; + +const uploadRuntimeIfNeeded = async ( + target: IIdeSshTarget, + runtime: Awaited>, + batchMode: boolean, + progress: (message: string) => void, +) => { + progress(`Checking remote /tmp cache at ${runtime.remoteRoot}.`); + const cacheCheckCommand = plugins.ideServerInstaller.createRemoteEphemeralRuntimeCacheCheckCommand({ + runtimeRoot: runtime.remoteRoot, + runtimeSha256: runtime.contentHash, + }); + const cacheCheckResult = await plugins.ideSsh.runSshCommand(target, cacheCheckCommand, { + timeoutMs: 30000, + batchMode, + }); + if (cacheCheckResult.exitCode === 0) { + progress(`Remote runtime hash matches; skipping copy for ${runtime.contentHash.slice(0, 12)}.`); + return; + } + + progress(`Remote runtime cache miss; copying ${formatBytes(runtime.totalBytes)} to ${runtime.remoteRoot}.`); + const reportUploadProgress = createUploadProgressReporter(progress, runtime.totalBytes); + const uploadResult = await plugins.ideSsh.uploadDirectoryToRemote(target, runtime.localRoot, runtime.remoteRoot, { + timeoutMs: 300000, + batchMode, + onProgress: (uploadProgress) => reportUploadProgress(uploadProgress.bytesUploaded), + }); + if (uploadResult.exitCode !== 0) { + throw new Error(uploadResult.stderr || `Remote runtime upload failed with ${uploadResult.exitCode}`); + } + progress('Remote runtime files copied; writing cache marker.'); + const markCommand = plugins.ideServerInstaller.createRemoteEphemeralRuntimeMarkCommand({ + runtimeRoot: runtime.remoteRoot, + runtimeSha256: runtime.contentHash, + }); + const markResult = await plugins.ideSsh.runSshCommand(target, markCommand, { + timeoutMs: 30000, + batchMode, + }); + if (markResult.exitCode !== 0) { + throw new Error(markResult.stderr || `Remote runtime marker write failed with ${markResult.exitCode}`); + } + progress(`Remote runtime upload complete; cache marker ${runtime.contentHash.slice(0, 12)} stored.`); +}; + +const loadRemoteProjects = async (target: IIdeSshTarget, runtimeRoot: string, batchMode: boolean) => { + const listResult = await plugins.ideSsh.runSshCommand( + target, + plugins.ideServerInstaller.createRemoteProjectListCommand({ runtimeRoot }), + { + timeoutMs: 30000, + batchMode, + }, + ); + if (listResult.exitCode !== 0) { + throw new Error(listResult.stderr || `Remote project registry read failed with ${listResult.exitCode}`); + } + return parseRemoteProjectRegistry(listResult.stdout); +}; + +const parseRemoteProjectRegistry = (text: string): IRemoteProject[] => { + const parsed = JSON.parse(text || '{"projects":[]}') as { projects?: unknown }; + if (!Array.isArray(parsed.projects)) { + return []; + } + return parsed.projects + .map((project) => normalizeRemoteProject(project)) + .filter((project): project is IRemoteProject => !!project); +}; + +const normalizeRemoteProject = (project: unknown): IRemoteProject | undefined => { + if (!project || typeof project !== 'object') { + return undefined; + } + const record = project as Record; + const pathValue = typeof record.path === 'string' ? record.path : undefined; + const id = typeof record.id === 'string' ? record.id : undefined; + if (!pathValue || !id) { + return undefined; + } + const title = typeof record.title === 'string' && record.title.trim() + ? record.title + : pathValue.split('/').filter(Boolean).pop() || pathValue; + return { + id, + path: pathValue, + title, + createdAt: typeof record.createdAt === 'string' ? record.createdAt : undefined, + updatedAt: typeof record.updatedAt === 'string' ? record.updatedAt : undefined, + }; +}; + +const parseAllocatedPorts = (stdout: string, expectedCount: number) => { + const portsLine = stdout.split(/\r?\n/).find((line) => line.startsWith('ports=')); + const ports = portsLine + ?.slice('ports='.length) + .split(',') + .map((value) => Number(value)) + .filter((port) => Number.isInteger(port) && port > 0 && port <= 65535) ?? []; + if (ports.length < expectedCount) { + throw new Error(`Expected ${expectedCount} remote ports, got: ${stdout}`); + } + return ports; +}; + const createProgressEmitter = (webContents: { isDestroyed(): boolean; send(channel: string, ...args: unknown[]): void }) => { return (message: string) => { console.log(`[connect] ${message}`); @@ -304,7 +913,9 @@ const createLocalEphemeralRuntime = async (serverVersion: string) => { const stageId = `gitzone-ide-stage-${sanitizeRuntimePart(serverVersion)}-${Date.now()}-${plugins.crypto.randomBytes(4).toString('hex')}`; const localRoot = path.join(os.tmpdir(), stageId); const sourceLib = path.join(workspaceRoot, 'applications', 'remote-theia', 'lib'); + const sourcePackageJson = path.join(workspaceRoot, 'applications', 'remote-theia', 'package.json'); const targetLib = path.join(localRoot, 'applications', 'remote-theia', 'lib'); + const targetPackageJson = path.join(localRoot, 'applications', 'remote-theia', 'package.json'); const nodeBinary = await resolveLocalNodeBinary(); const targetNodeBinary = path.join(localRoot, 'node', 'bin', 'node'); @@ -312,6 +923,7 @@ const createLocalEphemeralRuntime = async (serverVersion: string) => { await fs.access(path.join(sourceLib, 'backend', 'main.js')); await fs.mkdir(path.dirname(targetLib), { recursive: true }); await fs.cp(sourceLib, targetLib, { recursive: true }); + await fs.copyFile(sourcePackageJson, targetPackageJson); await fs.mkdir(path.dirname(targetNodeBinary), { recursive: true }); await fs.copyFile(nodeBinary, targetNodeBinary); await fs.chmod(targetNodeBinary, 0o755); @@ -473,166 +1085,286 @@ const renderLauncherHtml = () => ` Git.Zone IDE
-
Git.Zone IDE
+
Git.Zone IDENot connected
+
- -
diff --git a/applications/electron-shell/ts/plugins.ts b/applications/electron-shell/ts/plugins.ts index 2df2b8b..26e5428 100644 --- a/applications/electron-shell/ts/plugins.ts +++ b/applications/electron-shell/ts/plugins.ts @@ -1,6 +1,8 @@ import * as crypto from 'node:crypto'; +import * as http from 'node:http'; import * as electron from 'electron'; +import * as ideOpenCodeBridge from '@git.zone/ide-opencode-bridge'; import * as ideServerInstaller from '@git.zone/ide-server-installer'; import * as ideSsh from '@git.zone/ide-ssh'; -export { crypto, electron, ideServerInstaller, ideSsh }; +export { crypto, electron, http, ideOpenCodeBridge, ideServerInstaller, ideSsh }; diff --git a/applications/remote-theia/package.json b/applications/remote-theia/package.json index 2eeb502..4227fc6 100644 --- a/applications/remote-theia/package.json +++ b/applications/remote-theia/package.json @@ -41,12 +41,14 @@ "target": "browser", "backend": { "config": { + "singleInstance": false, "configurationFolder": ".git.zone/ide/theia" } }, "frontend": { "config": { "applicationName": "Git.Zone IDE", + "defaultTheme": "dark", "preferencesDirName": ".git-zone-ide" } } diff --git a/applications/remote-theia/src-gen/backend/main.js b/applications/remote-theia/src-gen/backend/main.js index 801e901..b8ffb1a 100644 --- a/applications/remote-theia/src-gen/backend/main.js +++ b/applications/remote-theia/src-gen/backend/main.js @@ -6,7 +6,7 @@ const { BackendApplicationConfigProvider } = require('@theia/core/lib/node/backe const main = require('@theia/core/lib/node/main'); BackendApplicationConfigProvider.set({ - "singleInstance": true, + "singleInstance": false, "frontendConnectionTimeout": 0, "configurationFolder": ".git.zone/ide/theia" }); diff --git a/applications/remote-theia/src-gen/frontend/index.js b/applications/remote-theia/src-gen/frontend/index.js index e4e5883..dde6577 100644 --- a/applications/remote-theia/src-gen/frontend/index.js +++ b/applications/remote-theia/src-gen/frontend/index.js @@ -7,10 +7,7 @@ const { FrontendApplicationConfigProvider } = require('@theia/core/lib/browser/f FrontendApplicationConfigProvider.set({ "applicationName": "Git.Zone IDE", - "defaultTheme": { - "light": "light", - "dark": "dark" - }, + "defaultTheme": "dark", "defaultIconTheme": "theia-file-icons", "electron": { "windowOptions": {}, diff --git a/packages/opencode-bridge/ts/index.ts b/packages/opencode-bridge/ts/index.ts index 18454b6..2d057b7 100644 --- a/packages/opencode-bridge/ts/index.ts +++ b/packages/opencode-bridge/ts/index.ts @@ -287,6 +287,155 @@ export class OpenCodeServerClient { } } +export const openCodeBridgeToolNames = [ + 'bash', + 'read', + 'write', + 'edit', + 'grep', + 'glob', + 'apply_patch', +] as const; + +export type TOpenCodeBridgeToolName = typeof openCodeBridgeToolNames[number]; + +export interface IOpenCodeBridgeConfigRenderOptions { + bridgeUrlEnvName?: string; + bridgeTokenEnvName?: string; +} + +export const renderOpenCodeBridgeConfigContent = () => `${JSON.stringify({ + $schema: 'https://opencode.ai/config.json', + autoupdate: false, + snapshot: false, + permission: { + lsp: 'deny', + skill: 'deny', + }, +}, undefined, 2)}\n`; + +export const renderOpenCodeBridgeToolFiles = (options: IOpenCodeBridgeConfigRenderOptions = {}) => { + const files: Record = {}; + for (const toolName of openCodeBridgeToolNames) { + files[`tools/${toolName}.js`] = renderOpenCodeBridgeToolFile(toolName, options); + } + return files; +}; + +export const renderOpenCodeBridgeToolFile = ( + toolName: TOpenCodeBridgeToolName, + options: IOpenCodeBridgeConfigRenderOptions = {}, +) => { + const bridgeUrlEnvName = options.bridgeUrlEnvName ?? 'GITZONE_IDE_TOOL_BRIDGE_URL'; + const bridgeTokenEnvName = options.bridgeTokenEnvName ?? 'GITZONE_IDE_TOOL_BRIDGE_TOKEN'; + const definition = openCodeBridgeToolDefinitions[toolName]; + return `import { tool } from "@opencode-ai/plugin"; + +const bridgeUrlEnvName = ${JSON.stringify(bridgeUrlEnvName)}; +const bridgeTokenEnvName = ${JSON.stringify(bridgeTokenEnvName)}; + +const forwardTool = async (toolName, args, context) => { + const bridgeUrl = process.env[bridgeUrlEnvName]; + const bridgeToken = process.env[bridgeTokenEnvName]; + if (!bridgeUrl || !bridgeToken) { + throw new Error("Git.Zone OpenCode tool bridge is not configured."); + } + const response = await fetch(new URL("/tool/" + encodeURIComponent(toolName), bridgeUrl), { + method: "POST", + headers: { + "authorization": "Bearer " + bridgeToken, + "content-type": "application/json", + }, + body: JSON.stringify({ + args, + context: { + sessionID: context.sessionID, + messageID: context.messageID, + agent: context.agent, + directory: context.directory, + worktree: context.worktree, + }, + }), + }); + const responseText = await response.text(); + if (!response.ok) { + throw new Error(responseText || ("Git.Zone OpenCode tool bridge failed with HTTP " + response.status)); + } + return responseText ? JSON.parse(responseText) : ""; +}; + +export default tool({ + description: ${JSON.stringify(definition.description)}, + args: { +${definition.args.map((arg) => ` ${arg.name}: ${arg.schema},`).join('\n')} + }, + async execute(args, context) { + return forwardTool(${JSON.stringify(toolName)}, args, context); + }, +}); +`; +}; + +const openCodeBridgeToolDefinitions: Record; +}> = { + bash: { + description: 'Execute a shell command on the selected remote Git.Zone workspace over SSH. The command runs on the remote host, not on the local proxy workspace. Use workdir for a remote working directory when needed.', + args: [ + { name: 'command', schema: 'tool.schema.string().describe("The command to execute on the remote host")' }, + { name: 'timeout', schema: 'tool.schema.number().optional().describe("Optional timeout in milliseconds")' }, + { name: 'workdir', schema: 'tool.schema.string().optional().describe("Remote working directory. Defaults to the selected remote project path.")' }, + { name: 'description', schema: 'tool.schema.string().describe("Clear, concise description of what this command does in 5-10 words")' }, + ], + }, + read: { + description: 'Read a file or directory from the selected remote Git.Zone workspace. Paths may be remote absolute paths or paths relative to the selected remote project.', + args: [ + { name: 'filePath', schema: 'tool.schema.string().describe("Remote file or directory path to read")' }, + { name: 'offset', schema: 'tool.schema.number().optional().describe("The line number to start reading from (1-indexed)")' }, + { name: 'limit', schema: 'tool.schema.number().optional().describe("The maximum number of lines to read")' }, + ], + }, + write: { + description: 'Create or overwrite a file in the selected remote Git.Zone workspace.', + args: [ + { name: 'filePath', schema: 'tool.schema.string().describe("Remote file path to write")' }, + { name: 'content', schema: 'tool.schema.string().describe("The content to write to the remote file")' }, + ], + }, + edit: { + description: 'Modify an existing remote file using exact string replacement.', + args: [ + { name: 'filePath', schema: 'tool.schema.string().describe("Remote file path to modify")' }, + { name: 'oldString', schema: 'tool.schema.string().describe("The text to replace")' }, + { name: 'newString', schema: 'tool.schema.string().describe("The replacement text")' }, + { name: 'replaceAll', schema: 'tool.schema.boolean().optional().describe("Replace all occurrences of oldString")' }, + ], + }, + grep: { + description: 'Search remote file contents in the selected Git.Zone workspace using ripgrep on the remote host.', + args: [ + { name: 'pattern', schema: 'tool.schema.string().describe("The regex pattern to search for in file contents")' }, + { name: 'path', schema: 'tool.schema.string().optional().describe("Remote directory or file to search. Defaults to the selected remote project path.")' }, + { name: 'include', schema: 'tool.schema.string().optional().describe("File glob to include, for example *.ts or *.{ts,tsx}")' }, + ], + }, + glob: { + description: 'Find remote files by glob pattern in the selected Git.Zone workspace using ripgrep on the remote host.', + args: [ + { name: 'pattern', schema: 'tool.schema.string().describe("The glob pattern to match remote files against")' }, + { name: 'path', schema: 'tool.schema.string().optional().describe("Remote directory to search. Defaults to the selected remote project path.")' }, + ], + }, + apply_patch: { + description: 'Apply a stripped-down file patch to the selected remote Git.Zone workspace. Patch paths are relative to the selected remote project unless absolute remote paths are supplied.', + args: [ + { name: 'patchText', schema: 'tool.schema.string().describe("The full patch text that describes all remote file changes to make")' }, + ], + }, +}; + export const parseServerSentEvent = (raw: string): IOpenCodeEvent | undefined => { const trimmed = raw.trim(); if (!trimmed) { diff --git a/packages/server-installer/ts/index.ts b/packages/server-installer/ts/index.ts index b0d4cdc..65804ff 100644 --- a/packages/server-installer/ts/index.ts +++ b/packages/server-installer/ts/index.ts @@ -30,6 +30,7 @@ export interface IRemoteServerBootstrapOptions { opencodePassword: string; installRoot?: string; nodeEnv?: string; + theiaColorTheme?: string; } export interface IRemoteEphemeralBootstrapOptions extends IRemoteServerBootstrapOptions { @@ -60,9 +61,36 @@ export interface IRemoteEphemeralRuntimeMarkOptions { nodePath?: string; } +export interface IRemoteEphemeralPortAllocationOptions { + runtimeRoot: string; + count?: number; + nodePath?: string; +} + +export interface IRemoteProjectRegistryOptions { + runtimeRoot: string; + ideDataRoot?: string; + nodePath?: string; +} + +export interface IRemoteProjectUpsertOptions extends IRemoteProjectRegistryOptions { + projectPath: string; + title?: string; +} + +export interface IRemoteOpenCodeToolCommandOptions { + runtimeRoot: string; + workspacePath: string; + toolName: string; + nodePath?: string; + rgPath?: string; +} + export const defaultIdeDataRoot = '~/.git.zone/ide'; export const defaultInstallRoot = '~/.git.zone/ide/server'; export const remoteEphemeralRuntimeMarkerFileName = '.gitzone-runtime-sha256'; +export const remoteProjectsFileName = 'projects.json'; +export const defaultTheiaColorTheme = 'dark'; export const createRemoteServerInstallPlan = ( options: IRemoteServerInstallPlanOptions, @@ -120,6 +148,7 @@ export const createRemoteBootstrapCommand = (options: IRemoteServerBootstrapOpti const env = { GITZONE_IDE_WORKSPACE: options.workspacePath, GITZONE_IDE_OPENCODE_PORT: `${options.opencodePort}`, + GITZONE_IDE_DISABLE_OPENCODE_AUTOSTART: '1', OPENCODE_SERVER_USERNAME: options.opencodeUsername, OPENCODE_SERVER_PASSWORD: options.opencodePassword, NODE_ENV: options.nodeEnv ?? 'production', @@ -148,10 +177,27 @@ export const createRemoteEphemeralBootstrapCommand = (options: IRemoteEphemeralB const ideDataRoot = options.ideDataRoot ?? defaultIdeDataRoot; const logsDir = joinRemotePath(ideDataRoot, 'logs'); const theiaConfigDir = joinRemotePath(ideDataRoot, 'theia'); + const theiaSettingsPath = joinRemotePath(theiaConfigDir, 'settings.json'); const logFile = joinRemotePath(logsDir, `theia-${options.theiaPort}.log`); + const theiaColorTheme = options.theiaColorTheme ?? defaultTheiaColorTheme; + const themePreferenceScript = [ + "const fs = require('fs');", + 'const settingsPath = process.env.GITZONE_IDE_THEIA_SETTINGS;', + 'const colorTheme = process.env.GITZONE_IDE_THEIA_COLOR_THEME;', + 'let settings = {};', + 'try {', + " const raw = fs.readFileSync(settingsPath, 'utf8').trim();", + ' settings = raw ? JSON.parse(raw) : {};', + '} catch (error) {', + " if (!error || error.code !== 'ENOENT') throw error;", + '}', + "settings['workbench.colorTheme'] = colorTheme;", + "fs.writeFileSync(settingsPath, `${JSON.stringify(settings, undefined, 2)}\\n`);", + ].join('\n'); const env = { GITZONE_IDE_WORKSPACE: options.workspacePath, GITZONE_IDE_OPENCODE_PORT: `${options.opencodePort}`, + GITZONE_IDE_DISABLE_OPENCODE_AUTOSTART: '1', OPENCODE_SERVER_USERNAME: options.opencodeUsername, OPENCODE_SERVER_PASSWORD: options.opencodePassword, NODE_ENV: options.nodeEnv ?? 'production', @@ -160,11 +206,14 @@ export const createRemoteEphemeralBootstrapCommand = (options: IRemoteEphemeralB return [ 'set -euo pipefail', `mkdir -p ${quoteRemotePath(logsDir)} ${quoteRemotePath(theiaConfigDir)}`, + `export LD_LIBRARY_PATH=${quoteRemotePath(joinRemotePath(options.runtimeRoot, 'node/lib'))}:\${LD_LIBRARY_PATH:-}`, `test -x ${quoteRemotePath(nodePath)} || { printf 'bundled node not executable: %s\n' ${quoteShellArg(nodePath)} >&2; exit 1; }`, `test -f ${quoteRemotePath(joinRemotePath(appDir, 'lib/backend/main.js'))} || { printf 'bundled Theia backend missing: %s\n' ${quoteShellArg(appDir)} >&2; exit 1; }`, `test -d ${quoteRemotePath(options.workspacePath)} || { printf 'workspace path not found: %s\n' ${quoteShellArg(options.workspacePath)} >&2; exit 1; }`, + `GITZONE_IDE_THEIA_SETTINGS=${quoteRemotePath(theiaSettingsPath)} GITZONE_IDE_THEIA_COLOR_THEME=${quoteShellArg(theiaColorTheme)} ${quoteRemotePath(nodePath)} <<'GITZONE_IDE_THEME'`, + themePreferenceScript, + 'GITZONE_IDE_THEME', `cd ${quoteRemotePath(options.workspacePath)}`, - `export LD_LIBRARY_PATH=${quoteRemotePath(joinRemotePath(options.runtimeRoot, 'node/lib'))}:\${LD_LIBRARY_PATH:-}`, `export THEIA_CONFIG_DIR=${quoteRemotePath(theiaConfigDir)}`, ...Object.entries(env).map(([key, value]) => { const renderedValue = key === 'GITZONE_IDE_WORKSPACE' ? quoteRemotePath(value) : quoteShellArg(value); @@ -233,6 +282,107 @@ export const createRemoteEphemeralRuntimeMarkCommand = (options: IRemoteEphemera ].join('\n'); }; +export const createRemoteEphemeralPortAllocationCommand = (options: IRemoteEphemeralPortAllocationOptions) => { + const nodePath = options.nodePath ?? joinRemotePath(options.runtimeRoot, 'node/bin/node'); + const count = options.count ?? 1; + const script = [ + "const net = require('net');", + `const count = ${JSON.stringify(count)};`, + 'const ports = [];', + 'const servers = [];', + 'const listen = () => new Promise((resolve, reject) => {', + ' const server = net.createServer();', + " server.on('error', reject);", + " server.listen(0, '127.0.0.1', () => {", + ' ports.push(server.address().port);', + ' servers.push(server);', + ' resolve();', + ' });', + '});', + '(async () => {', + ' for (let index = 0; index < count; index++) await listen();', + " console.log(`ports=${ports.join(',')}`);", + ' await Promise.all(servers.map((server) => new Promise((resolve) => server.close(resolve))));', + '})().catch((error) => { console.error(error.stack || String(error)); process.exit(1); });', + ].join('\n'); + + return [ + 'set -euo pipefail', + `export LD_LIBRARY_PATH=${quoteRemotePath(joinRemotePath(options.runtimeRoot, 'node/lib'))}:\${LD_LIBRARY_PATH:-}`, + `${quoteRemotePath(nodePath)} -e ${quoteShellArg(script)}`, + ].join('\n'); +}; + +export const createRemoteOpenCodeToolCommand = (options: IRemoteOpenCodeToolCommandOptions) => { + const nodePath = options.nodePath ?? joinRemotePath(options.runtimeRoot, 'node/bin/node'); + const rgPath = options.rgPath ?? joinRemotePath(options.runtimeRoot, 'applications/remote-theia/lib/backend/native/rg'); + + return [ + 'set -euo pipefail', + `export LD_LIBRARY_PATH=${quoteRemotePath(joinRemotePath(options.runtimeRoot, 'node/lib'))}:\${LD_LIBRARY_PATH:-}`, + `export GITZONE_IDE_WORKSPACE=${quoteRemotePath(options.workspacePath)}`, + `export GITZONE_IDE_TOOL_NAME=${quoteShellArg(options.toolName)}`, + `export GITZONE_IDE_RG_PATH=${quoteRemotePath(rgPath)}`, + `${quoteRemotePath(nodePath)} -e ${quoteShellArg(remoteOpenCodeToolScript)}`, + ].join('\n'); +}; + +export const createRemoteProjectListCommand = (options: IRemoteProjectRegistryOptions) => { + const projectsFile = joinRemotePath(options.ideDataRoot ?? defaultIdeDataRoot, remoteProjectsFileName); + + return [ + 'set -euo pipefail', + `if test -f ${quoteRemotePath(projectsFile)}; then`, + ` cat ${quoteRemotePath(projectsFile)}`, + 'else', + " printf '{\"projects\":[]}\n'", + 'fi', + ].join('\n'); +}; + +export const createRemoteProjectUpsertCommand = (options: IRemoteProjectUpsertOptions) => { + const ideDataRoot = options.ideDataRoot ?? defaultIdeDataRoot; + const projectsFile = joinRemotePath(ideDataRoot, remoteProjectsFileName); + const nodePath = options.nodePath ?? joinRemotePath(options.runtimeRoot, 'node/bin/node'); + const script = [ + "const crypto = require('crypto');", + "const fs = require('fs');", + "const path = require('path');", + 'const projectsFile = process.env.GITZONE_IDE_PROJECTS_FILE;', + 'const projectPath = process.env.GITZONE_IDE_PROJECT_PATH;', + 'const title = process.env.GITZONE_IDE_PROJECT_TITLE || path.basename(projectPath) || projectPath;', + "let registry = { projects: [] };", + "try { registry = JSON.parse(fs.readFileSync(projectsFile, 'utf8')); } catch {}", + "if (!Array.isArray(registry.projects)) registry.projects = [];", + "const id = crypto.createHash('sha256').update(projectPath).digest('hex').slice(0, 16);", + 'const now = new Date().toISOString();', + 'const existing = registry.projects.find((project) => project.id === id || project.path === projectPath);', + 'if (existing) {', + ' existing.id = id;', + ' existing.path = projectPath;', + ' existing.title = title;', + ' existing.updatedAt = now;', + '} else {', + ' registry.projects.push({ id, path: projectPath, title, createdAt: now, updatedAt: now });', + '}', + 'registry.projects.sort((left, right) => left.title.localeCompare(right.title));', + 'fs.mkdirSync(path.dirname(projectsFile), { recursive: true });', + "fs.writeFileSync(`${projectsFile}.tmp`, `${JSON.stringify(registry, undefined, 2)}\\n`);", + "fs.renameSync(`${projectsFile}.tmp`, projectsFile);", + 'console.log(JSON.stringify(registry));', + ].join('\n'); + + return [ + 'set -euo pipefail', + `export GITZONE_IDE_PROJECTS_FILE=${quoteRemotePath(projectsFile)}`, + `export GITZONE_IDE_PROJECT_PATH=${quoteRemotePath(options.projectPath)}`, + `export GITZONE_IDE_PROJECT_TITLE=${quoteShellArg(options.title ?? '')}`, + `test -d "$GITZONE_IDE_PROJECT_PATH" || { printf 'workspace path not found: %s\n' "$GITZONE_IDE_PROJECT_PATH" >&2; exit 1; }`, + `export LD_LIBRARY_PATH=${quoteRemotePath(joinRemotePath(options.runtimeRoot, 'node/lib'))}:\${LD_LIBRARY_PATH:-}`, + `${quoteRemotePath(nodePath)} -e ${quoteShellArg(script)}`, + ].join('\n'); +}; + export const createRemoteHealthCommand = (serverVersion: string, installRoot = defaultInstallRoot) => { const plan = createRemoteServerInstallPlan({ serverVersion, @@ -246,6 +396,199 @@ export const createRemoteHealthCommand = (serverVersion: string, installRoot = d ].join('\n'); }; +export const remoteOpenCodeToolScript = [ + "const fs = require('fs');", + "const path = require('path');", + "const childProcess = require('child_process');", + "const toolName = process.env.GITZONE_IDE_TOOL_NAME;", + "const workspacePath = expandHome(process.env.GITZONE_IDE_WORKSPACE || process.cwd());", + "const rgPath = process.env.GITZONE_IDE_RG_PATH || 'rg';", + "const inputText = fs.readFileSync(0, 'utf8');", + "const request = inputText.trim() ? JSON.parse(inputText) : {};", + "const args = request.args || {};", + "const MAX_LINES = 2000;", + "const MAX_LINE_LENGTH = 2000;", + "const MAX_BYTES = 50 * 1024;", + "function expandHome(value) {", + " const text = String(value || '');", + " if (text === '~' || text === '$HOME') return process.env.HOME || text;", + " if (text.startsWith('~/')) return path.join(process.env.HOME || '', text.slice(2));", + " if (text.startsWith('$HOME/')) return path.join(process.env.HOME || '', text.slice(6));", + " return text;", + "}", + "function normalizeRemotePath(value, fallback) {", + " if (!value) return fallback;", + " const expanded = expandHome(value);", + " return path.isAbsolute(expanded) ? path.normalize(expanded) : path.resolve(fallback, expanded);", + "}", + "function limitLine(line) {", + " return line.length > MAX_LINE_LENGTH ? line.slice(0, MAX_LINE_LENGTH) + '... (line truncated to 2000 chars)' : line;", + "}", + "function writeJson(result) { process.stdout.write(JSON.stringify(result)); }", + "function ensureParent(filePath) { fs.mkdirSync(path.dirname(filePath), { recursive: true }); }", + "function toLines(text) {", + " if (!text) return [];", + " const lines = text.split(/\\r?\\n/);", + " if (text.endsWith('\\n')) lines.pop();", + " return lines;", + "}", + "function readTool() {", + " const filePath = normalizeRemotePath(args.filePath, workspacePath);", + " const stat = fs.statSync(filePath);", + " const offset = Math.max(1, Number(args.offset || 1));", + " const limit = Math.max(0, Number(args.limit || MAX_LINES));", + " if (stat.isDirectory()) {", + " const entries = fs.readdirSync(filePath, { withFileTypes: true }).map((entry) => entry.name + (entry.isDirectory() ? '/' : '')).sort((a, b) => a.localeCompare(b));", + " const sliced = entries.slice(offset - 1, offset - 1 + limit);", + " const truncated = offset - 1 + sliced.length < entries.length;", + " return { output: ['' + filePath + '', 'directory', '', sliced.join('\\n'), truncated ? '\\n(Showing ' + sliced.length + ' of ' + entries.length + ' entries. Use offset=' + (offset + sliced.length) + ' to continue.)' : '\\n(' + entries.length + ' entries)', ''].join('\\n'), metadata: { preview: sliced.slice(0, 20).join('\\n'), truncated } };", + " }", + " const buffer = fs.readFileSync(filePath);", + " if (buffer.includes(0)) throw new Error('Cannot read binary file: ' + filePath);", + " const lines = toLines(buffer.toString('utf8'));", + " if (offset > lines.length && !(lines.length === 0 && offset === 1)) throw new Error('Offset ' + offset + ' is out of range for this file (' + lines.length + ' lines)');", + " const raw = [];", + " let bytes = 0;", + " let cut = false;", + " for (const line of lines.slice(offset - 1)) {", + " if (raw.length >= limit) break;", + " const next = limitLine(line);", + " const size = Buffer.byteLength(next, 'utf8') + (raw.length > 0 ? 1 : 0);", + " if (bytes + size > MAX_BYTES) { cut = true; break; }", + " raw.push(next);", + " bytes += size;", + " }", + " const last = offset + raw.length - 1;", + " const more = cut || last < lines.length;", + " let output = '' + filePath + '\\nfile\\n\\n';", + " output += raw.map((line, index) => (index + offset) + ': ' + line).join('\\n');", + " output += more ? '\\n\\n(Showing lines ' + offset + '-' + last + ' of ' + lines.length + '. Use offset=' + (last + 1) + ' to continue.)' : '\\n\\n(End of file - total ' + lines.length + ' lines)';", + " output += '\\n';", + " return { output, metadata: { preview: raw.slice(0, 20).join('\\n'), truncated: more } };", + "}", + "function writeTool() {", + " const filePath = normalizeRemotePath(args.filePath, workspacePath);", + " const existed = fs.existsSync(filePath);", + " ensureParent(filePath);", + " fs.writeFileSync(filePath, String(args.content || ''));", + " return { output: 'Wrote file successfully.', metadata: { filepath: filePath, exists: existed } };", + "}", + "function editTool() {", + " const filePath = normalizeRemotePath(args.filePath, workspacePath);", + " if (args.oldString === args.newString) throw new Error('No changes to apply: oldString and newString are identical.');", + " let content = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : '';", + " let next;", + " if (args.oldString === '') {", + " next = String(args.newString || '');", + " } else {", + " const oldString = String(args.oldString || '');", + " const newString = String(args.newString || '');", + " const matches = content.split(oldString).length - 1;", + " if (matches === 0) throw new Error('Could not find oldString in the file. It must match exactly.');", + " if (!args.replaceAll && matches > 1) throw new Error('Found multiple matches for oldString. Provide more surrounding context or set replaceAll.');", + " next = args.replaceAll ? content.split(oldString).join(newString) : content.replace(oldString, newString);", + " }", + " ensureParent(filePath);", + " fs.writeFileSync(filePath, next);", + " return { output: 'Edit applied successfully.', metadata: { filepath: filePath } };", + "}", + "function bashTool() {", + " const cwd = normalizeRemotePath(args.workdir, workspacePath);", + " const timeout = Number(args.timeout || 120000);", + " const shell = process.env.SHELL || '/bin/sh';", + " const result = childProcess.spawnSync(shell, ['-lc', String(args.command || '')], { cwd, encoding: 'utf8', timeout, maxBuffer: 10 * 1024 * 1024 });", + " const outputText = [result.stdout || '', result.stderr || ''].filter(Boolean).join('');", + " const metadata = [];", + " if (result.error && result.error.code === 'ETIMEDOUT') metadata.push('remote shell tool terminated command after exceeding timeout ' + timeout + ' ms.');", + " metadata.push('exit=' + (result.status === null || result.status === undefined ? 1 : result.status));", + " const output = (outputText.trim() ? outputText.replace(/\\s+$/g, '') : '(no output)') + '\\n\\n\\n' + metadata.join('\\n') + '\\n';", + " return { output, metadata: { exit: result.status, cwd } };", + "}", + "function grepTool() {", + " if (!args.pattern) throw new Error('pattern is required');", + " const search = normalizeRemotePath(args.path, workspacePath);", + " const executable = fs.existsSync(rgPath) ? rgPath : 'rg';", + " const rgArgs = ['--line-number', '--with-filename', '--color', 'never', '--no-heading'];", + " if (args.include) rgArgs.push('--glob', String(args.include));", + " rgArgs.push('--', String(args.pattern), search);", + " const result = childProcess.spawnSync(executable, rgArgs, { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 });", + " if (result.status !== 0 && result.status !== 1) throw new Error(result.stderr || ('ripgrep failed with exit ' + result.status));", + " const rows = (result.stdout || '').split(/\\r?\\n/).filter(Boolean).map((line) => { const match = line.match(/^(.*?):(\\d+):(.*)$/); if (!match) return undefined; const filePath = path.resolve(match[1]); let mtime = 0; try { mtime = fs.statSync(filePath).mtimeMs; } catch {} return { path: filePath, line: Number(match[2]), text: match[3], mtime }; }).filter(Boolean);", + " rows.sort((left, right) => right.mtime - left.mtime);", + " const limit = 100;", + " const truncated = rows.length > limit;", + " const finalRows = truncated ? rows.slice(0, limit) : rows;", + " if (finalRows.length === 0) return { output: 'No files found', metadata: { matches: 0, truncated: false } };", + " const output = ['Found ' + rows.length + ' matches' + (truncated ? ' (showing first ' + limit + ')' : '')];", + " let current = '';", + " for (const row of finalRows) { if (current !== row.path) { if (current) output.push(''); current = row.path; output.push(row.path + ':'); } output.push(' Line ' + row.line + ': ' + limitLine(row.text)); }", + " if (truncated) output.push('', '(Results truncated: showing ' + limit + ' of ' + rows.length + ' matches.)');", + " return { output: output.join('\\n'), metadata: { matches: rows.length, truncated } };", + "}", + "function globTool() {", + " if (!args.pattern) throw new Error('pattern is required');", + " const search = normalizeRemotePath(args.path, workspacePath);", + " const executable = fs.existsSync(rgPath) ? rgPath : 'rg';", + " const result = childProcess.spawnSync(executable, ['--files', '--color', 'never', '--glob', String(args.pattern), '--', search], { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 });", + " if (result.status !== 0 && result.status !== 1) throw new Error(result.stderr || ('ripgrep failed with exit ' + result.status));", + " const files = (result.stdout || '').split(/\\r?\\n/).filter(Boolean).map((entry) => path.isAbsolute(entry) ? entry : path.resolve(search, entry)).map((filePath) => { let mtime = 0; try { mtime = fs.statSync(filePath).mtimeMs; } catch {} return { path: filePath, mtime }; }).sort((left, right) => right.mtime - left.mtime);", + " const limit = 100;", + " const truncated = files.length > limit;", + " const finalFiles = truncated ? files.slice(0, limit) : files;", + " const output = finalFiles.length ? finalFiles.map((file) => file.path) : ['No files found'];", + " if (truncated) output.push('', '(Results are truncated: showing first ' + limit + ' results.)');", + " return { output: output.join('\\n'), metadata: { count: finalFiles.length, truncated } };", + "}", + "function parsePatch(text) {", + " const lines = String(text || '').replace(/\\r\\n/g, '\\n').replace(/\\r/g, '\\n').split('\\n');", + " if (lines[0] !== '*** Begin Patch') throw new Error('apply_patch verification failed: missing begin marker');", + " const ops = [];", + " let index = 1;", + " while (index < lines.length) {", + " const line = lines[index];", + " if (line === '*** End Patch') break;", + " if (line.startsWith('*** Add File: ')) { const file = line.slice(14); const body = []; index++; while (index < lines.length && !lines[index].startsWith('*** ')) { if (!lines[index].startsWith('+')) throw new Error('add file lines must start with +'); body.push(lines[index].slice(1)); index++; } ops.push({ type: 'add', file, body }); continue; }", + " if (line.startsWith('*** Delete File: ')) { ops.push({ type: 'delete', file: line.slice(17) }); index++; continue; }", + " if (line.startsWith('*** Update File: ')) { const file = line.slice(17); const body = []; let moveTo; index++; if (lines[index] && lines[index].startsWith('*** Move to: ')) { moveTo = lines[index].slice(13); index++; } while (index < lines.length && !lines[index].startsWith('*** ')) { body.push(lines[index]); index++; } ops.push({ type: 'update', file, moveTo, body }); continue; }", + " throw new Error('apply_patch verification failed: unknown patch line ' + line);", + " }", + " if (ops.length === 0) throw new Error('patch rejected: empty patch');", + " return ops;", + "}", + "function replaceHunk(content, oldLines, newLines) {", + " const oldText = oldLines.join('\\n');", + " const newText = newLines.join('\\n');", + " const candidates = oldLines.length === 0 ? [['', newText]] : [[oldText + '\\n', newText + '\\n'], [oldText, newText]];", + " for (const pair of candidates) { const from = pair[0]; const to = pair[1]; const position = from === '' ? content.length : content.indexOf(from); if (position !== -1) return content.slice(0, position) + to + content.slice(position + from.length); }", + " throw new Error('apply_patch verification failed: hunk context not found');", + "}", + "function applyPatchTool() {", + " const ops = parsePatch(args.patchText);", + " const summary = [];", + " for (const op of ops) {", + " const filePath = normalizeRemotePath(op.file, workspacePath);", + " if (op.type === 'add') { ensureParent(filePath); fs.writeFileSync(filePath, op.body.join('\\n') + (op.body.length ? '\\n' : '')); summary.push('A ' + path.relative(workspacePath, filePath)); continue; }", + " if (op.type === 'delete') { fs.rmSync(filePath, { force: true }); summary.push('D ' + path.relative(workspacePath, filePath)); continue; }", + " let content = fs.readFileSync(filePath, 'utf8');", + " const groups = []; let current = [];", + " for (const line of op.body) { if (line.startsWith('@@')) { if (current.length) groups.push(current); current = []; continue; } current.push(line); }", + " if (current.length) groups.push(current);", + " for (const group of groups) { const oldLines = []; const newLines = []; for (const line of group) { if (!line) continue; const marker = line[0]; const value = line.slice(1); if (marker === ' ') { oldLines.push(value); newLines.push(value); } else if (marker === '-') { oldLines.push(value); } else if (marker === '+') { newLines.push(value); } else if (line.startsWith('\\\\ No newline')) { } else { throw new Error('apply_patch verification failed: invalid hunk line ' + line); } } content = replaceHunk(content, oldLines, newLines); }", + " const targetPath = op.moveTo ? normalizeRemotePath(op.moveTo, workspacePath) : filePath;", + " ensureParent(targetPath); fs.writeFileSync(targetPath, content); if (op.moveTo) fs.rmSync(filePath, { force: true }); summary.push('M ' + path.relative(workspacePath, targetPath));", + " }", + " return { output: 'Success. Updated the following files:\\n' + summary.join('\\n'), metadata: { files: summary } };", + "}", + "try {", + " const handlers = { bash: bashTool, read: readTool, write: writeTool, edit: editTool, grep: grepTool, glob: globTool, apply_patch: applyPatchTool };", + " if (!handlers[toolName]) throw new Error('Unsupported Git.Zone OpenCode tool: ' + toolName);", + " writeJson(handlers[toolName]());", + "} catch (error) {", + " console.error(error && error.stack ? error.stack : String(error));", + " process.exit(1);", + "}", +].join('\n'); + export const quoteShellArg = (value: string | number | boolean) => { const stringValue = String(value); if (stringValue.length === 0) { diff --git a/packages/ssh/ts/index.ts b/packages/ssh/ts/index.ts index b98a595..9108671 100644 --- a/packages/ssh/ts/index.ts +++ b/packages/ssh/ts/index.ts @@ -19,6 +19,7 @@ export interface ISshRunOptions { batchMode?: boolean; cwd?: string; env?: NodeJS.ProcessEnv; + stdin?: string | Buffer; } export interface ISshRunResult { @@ -232,7 +233,7 @@ export const runSshCommand = async ( cwd: options.cwd, env, shell: false, - stdio: ['ignore', 'pipe', 'pipe'], + stdio: options.stdin === undefined ? ['ignore', 'pipe', 'pipe'] : ['pipe', 'pipe', 'pipe'], windowsHide: true, }); @@ -247,8 +248,11 @@ export const runSshCommand = async ( }, options.timeoutMs) : undefined; - child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk)); - child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk)); + child.stdout!.on('data', (chunk: Buffer) => stdout.push(chunk)); + child.stderr!.on('data', (chunk: Buffer) => stderr.push(chunk)); + if (options.stdin !== undefined) { + child.stdin!.end(options.stdin); + } child.on('error', (error) => { finished = true; if (timeout) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f052dc5..aa456aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: applications/electron-shell: dependencies: + '@git.zone/ide-opencode-bridge': + specifier: workspace:* + version: link:../../packages/opencode-bridge '@git.zone/ide-protocol': specifier: workspace:* version: link:../../packages/protocol diff --git a/test/test.installer.node.ts b/test/test.installer.node.ts index 3e23c0e..849b8f0 100644 --- a/test/test.installer.node.ts +++ b/test/test.installer.node.ts @@ -1,9 +1,18 @@ import { tap, expect } from '@git.zone/tstest/tapbundle'; +import * as childProcess from 'node:child_process'; +import * as fs from 'node:fs/promises'; +import * as os from 'node:os'; +import * as path from 'node:path'; import { createRemoteEphemeralBootstrapCommand, createRemoteEphemeralReadinessCommand, + createRemoteEphemeralPortAllocationCommand, createRemoteEphemeralRuntimeCacheCheckCommand, createRemoteEphemeralRuntimeMarkCommand, + createRemoteOpenCodeToolCommand, + createRemoteProjectListCommand, + createRemoteProjectUpsertCommand, + remoteOpenCodeToolScript, createRemoteBootstrapCommand, createRemoteInstallCommand, createRemoteServerInstallPlan, @@ -51,6 +60,7 @@ tap.test('should render install and bootstrap commands', async () => { expect(installCommand).toInclude('GITZONE_IDE_MANIFEST'); expect(installCommand).toInclude('"$HOME"/\'.git.zone/ide/server/0.1.0\''); expect(bootstrapCommand).toInclude('GITZONE_IDE_OPENCODE_PORT'); + expect(bootstrapCommand).toInclude('GITZONE_IDE_DISABLE_OPENCODE_AUTOSTART'); expect(bootstrapCommand).toInclude('pnpm --dir'); }); @@ -85,6 +95,9 @@ tap.test('should render ephemeral runtime bootstrap without remote pnpm', async expect(bootstrapCommand).not.toInclude('pnpm'); expect(bootstrapCommand).toInclude('LD_LIBRARY_PATH'); expect(bootstrapCommand).toInclude('THEIA_CONFIG_DIR="$HOME"/\'.git.zone/ide/theia\''); + expect(bootstrapCommand).toInclude('GITZONE_IDE_DISABLE_OPENCODE_AUTOSTART=\'1\''); + expect(bootstrapCommand).toInclude('GITZONE_IDE_THEIA_COLOR_THEME=\'dark\''); + expect(bootstrapCommand).toInclude("settings['workbench.colorTheme'] = colorTheme"); expect(bootstrapCommand).toInclude('"$HOME"/\'.git.zone/ide/logs\''); expect(bootstrapCommand).toInclude('runtimeRoot='); }); @@ -126,4 +139,86 @@ tap.test('should render ephemeral runtime mark command', async () => { expect(markCommand).toInclude('runtimeCache=stored'); }); +tap.test('should render remote port allocation command', async () => { + const portCommand = createRemoteEphemeralPortAllocationCommand({ + runtimeRoot: '/tmp/gitzone-ide-runtime-test', + count: 2, + }); + + expect(portCommand).toInclude('/tmp/gitzone-ide-runtime-test/node/bin/node'); + expect(portCommand).toInclude('ports='); + expect(portCommand).toInclude('LD_LIBRARY_PATH'); +}); + +tap.test('should render remote OpenCode tool bridge command', async () => { + const command = createRemoteOpenCodeToolCommand({ + runtimeRoot: '/tmp/gitzone-ide-runtime-test', + workspacePath: '$HOME/project', + toolName: 'read', + }); + + expect(command).toInclude('/tmp/gitzone-ide-runtime-test/node/bin/node'); + expect(command).toInclude('GITZONE_IDE_TOOL_NAME=\'read\''); + expect(command).toInclude('GITZONE_IDE_WORKSPACE="$HOME"/\'project\''); + expect(command).toInclude('GITZONE_IDE_RG_PATH'); + expect(command).toInclude('fs.readFileSync(0'); +}); + +tap.test('should execute remote OpenCode tool script with stdin payloads', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitzone-opencode-tool-')); + const filePath = path.join(tempDir, 'sample.txt'); + await fs.writeFile(filePath, 'hello\nworld\n'); + + const readResult = runRemoteOpenCodeToolScript('read', tempDir, { filePath: 'sample.txt' }); + expect(readResult.output).toInclude('1: hello'); + + const editResult = runRemoteOpenCodeToolScript('edit', tempDir, { + filePath: 'sample.txt', + oldString: 'world', + newString: 'remote', + }); + expect(editResult.output).toInclude('Edit applied successfully'); + expect(await fs.readFile(filePath, 'utf8')).toEqual('hello\nremote\n'); + + const patchResult = runRemoteOpenCodeToolScript('apply_patch', tempDir, { + patchText: '*** Begin Patch\n*** Add File: nested/new.txt\n+created remotely\n*** End Patch', + }); + expect(patchResult.output).toInclude('A nested/new.txt'); + expect(await fs.readFile(path.join(tempDir, 'nested', 'new.txt'), 'utf8')).toEqual('created remotely\n'); +}); + +tap.test('should render remote project registry commands', async () => { + const listCommand = createRemoteProjectListCommand({ + runtimeRoot: '/tmp/gitzone-ide-runtime-test', + }); + const upsertCommand = createRemoteProjectUpsertCommand({ + runtimeRoot: '/tmp/gitzone-ide-runtime-test', + projectPath: '$HOME/project', + title: 'Project', + }); + + expect(listCommand).toInclude('projects.json'); + expect(listCommand).toInclude('{"projects":[]}'); + expect(upsertCommand).toInclude('GITZONE_IDE_PROJECT_PATH="$HOME"/\'project\''); + expect(upsertCommand).toInclude('test -d "$GITZONE_IDE_PROJECT_PATH"'); + expect(upsertCommand).toInclude('crypto.createHash'); +}); + +const runRemoteOpenCodeToolScript = (toolName: string, workspacePath: string, args: Record) => { + const result = childProcess.spawnSync(process.execPath, ['-e', remoteOpenCodeToolScript], { + input: JSON.stringify({ args }), + encoding: 'utf8', + env: { + ...process.env, + GITZONE_IDE_TOOL_NAME: toolName, + GITZONE_IDE_WORKSPACE: workspacePath, + GITZONE_IDE_RG_PATH: '/not-found/rg', + }, + }); + if (result.status !== 0) { + throw new Error(result.stderr || `tool script failed with ${result.status}`); + } + return JSON.parse(result.stdout) as { output: string; metadata?: Record }; +}; + export default tap.start(); diff --git a/test/test.opencode-bridge.node.ts b/test/test.opencode-bridge.node.ts new file mode 100644 index 0000000..eb2bc85 --- /dev/null +++ b/test/test.opencode-bridge.node.ts @@ -0,0 +1,39 @@ +import { tap, expect } from '@git.zone/tstest/tapbundle'; +import { + openCodeBridgeToolNames, + renderOpenCodeBridgeConfigContent, + renderOpenCodeBridgeToolFile, + renderOpenCodeBridgeToolFiles, +} from '../packages/opencode-bridge/ts/index.js'; + +tap.test('should render managed OpenCode bridge config', async () => { + const config = JSON.parse(renderOpenCodeBridgeConfigContent()); + + expect(config.snapshot).toEqual(false); + expect(config.autoupdate).toEqual(false); + expect(config.permission.lsp).toEqual('deny'); + expect(config.permission.skill).toEqual('deny'); +}); + +tap.test('should render tool overrides for remote bridge', async () => { + const files = renderOpenCodeBridgeToolFiles(); + + for (const toolName of openCodeBridgeToolNames) { + expect(files[`tools/${toolName}.js`]).toInclude(`forwardTool(${JSON.stringify(toolName)}`); + } + expect(files['tools/bash.js']).toInclude('GITZONE_IDE_TOOL_BRIDGE_URL'); + expect(files['tools/apply_patch.js']).toInclude('patchText'); +}); + +tap.test('should allow custom bridge environment names', async () => { + const toolFile = renderOpenCodeBridgeToolFile('read', { + bridgeUrlEnvName: 'CUSTOM_BRIDGE_URL', + bridgeTokenEnvName: 'CUSTOM_BRIDGE_TOKEN', + }); + + expect(toolFile).toInclude('CUSTOM_BRIDGE_URL'); + expect(toolFile).toInclude('CUSTOM_BRIDGE_TOKEN'); + expect(toolFile).toInclude('filePath'); +}); + +export default tap.start(); diff --git a/test/test.theia-config.node.ts b/test/test.theia-config.node.ts index ca8073d..047f968 100644 --- a/test/test.theia-config.node.ts +++ b/test/test.theia-config.node.ts @@ -8,7 +8,9 @@ tap.test('should keep Theia backend config under Git.Zone IDE home path', async const packageJsonPath = path.join(process.cwd(), 'applications', 'remote-theia', 'package.json'); const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8')); + expect(packageJson.theia.backend.config.singleInstance).toEqual(false); expect(packageJson.theia.backend.config.configurationFolder).toEqual('.git.zone/ide/theia'); + expect(packageJson.theia.frontend.config.defaultTheme).toEqual('dark'); }); tap.test('should avoid legacy .theia workspace preference folders', async () => { diff --git a/theia-extensions/gitzone-opencode/lib/node/gitzone-opencode-node-service.d.ts.map b/theia-extensions/gitzone-opencode/lib/node/gitzone-opencode-node-service.d.ts.map index fa17ed8..bf73780 100644 --- a/theia-extensions/gitzone-opencode/lib/node/gitzone-opencode-node-service.d.ts.map +++ b/theia-extensions/gitzone-opencode/lib/node/gitzone-opencode-node-service.d.ts.map @@ -1 +1 @@ -{"version":3,"file":"gitzone-opencode-node-service.d.ts","sourceRoot":"","sources":["../../src/node/gitzone-opencode-node-service.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,MAAM,+BAA+B,CAAC;AACrE,OAAO,EAAE,8BAA8B,EAAE,MAAM,6CAA6C,CAAC;AAE7F,OAAO,KAAK,EACV,sBAAsB,EACtB,8BAA8B,EAC9B,0BAA0B,EAC1B,sBAAsB,EACvB,MAAM,wCAAwC,CAAC;AAChD,OAAO,KAAK,OAAO,MAAM,cAAc,CAAC;AAExC,qBACa,0BAA2B,YAAW,sBAAsB,EAAE,8BAA8B;IACvG,SAAS,CAAC,MAAM,EAAE,sBAAsB,GAAG,SAAS,CAAC;IACrD,SAAS,CAAC,oBAAoB,EAAE,eAAe,GAAG,SAAS,CAAC;IAC5D,SAAS,CAAC,eAAe,EAAE,OAAO,CAAC,YAAY,CAAC,YAAY,GAAG,SAAS,CAAC;IAEzE,UAAU,IAAI,IAAI;IAIlB,MAAM,IAAI,IAAI;IAOd,SAAS,CAAC,MAAM,EAAE,sBAAsB,GAAG,SAAS,GAAG,IAAI;IAKrD,iBAAiB,IAAI,OAAO,CAAC,8BAA8B,CAAC;IAS5D,MAAM,IAAI,OAAO,CAAC,OAAO,CAAC;IAK1B,SAAS,IAAI,OAAO,CAAC,OAAO,CAAC;IAI7B,MAAM,IAAI,OAAO,CAAC,OAAO,CAAC;IAI1B,QAAQ,IAAI,OAAO,CAAC,OAAO,CAAC;IAI5B,aAAa,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAI/C,QAAQ,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAI7D,MAAM,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,0BAA0B,GAAG,OAAO,CAAC,OAAO,CAAC;IAI7E,WAAW,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,0BAA0B,GAAG,OAAO,CAAC,IAAI,CAAC;IAI/E,OAAO,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,gBAAgB,SAAK,GAAG,OAAO,CAAC,OAAO,CAAC;IAIpF,KAAK,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAI1C,IAAI,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAI7D,IAAI,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAIzC,mBAAmB,CACvB,SAAS,EAAE,MAAM,EACjB,YAAY,EAAE,MAAM,EACpB,QAAQ,EAAE,MAAM,EAChB,QAAQ,CAAC,EAAE,OAAO,GACjB,OAAO,CAAC,OAAO,CAAC;cAIH,qBAAqB,IAAI,OAAO,CAAC,IAAI,CAAC;IA2BtD,SAAS,CAAC,kBAAkB,IAAI,IAAI;IAmBpC,SAAS,CAAC,YAAY;IAQtB,SAAS,KAAK,aAAa,WAE1B;IAED,SAAS,KAAK,IAAI,WAGjB;IAED,SAAS,KAAK,OAAO,WAEpB;IAED,SAAS,KAAK,QAAQ,WAErB;IAED,SAAS,KAAK,QAAQ,WAErB;IAED,SAAS,KAAK,SAAS,YAEtB;CACF"} \ No newline at end of file +{"version":3,"file":"gitzone-opencode-node-service.d.ts","sourceRoot":"","sources":["../../src/node/gitzone-opencode-node-service.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,oBAAoB,EAAE,MAAM,+BAA+B,CAAC;AACrE,OAAO,EAAE,8BAA8B,EAAE,MAAM,6CAA6C,CAAC;AAE7F,OAAO,KAAK,EACV,sBAAsB,EACtB,8BAA8B,EAC9B,0BAA0B,EAC1B,sBAAsB,EACvB,MAAM,wCAAwC,CAAC;AAChD,OAAO,KAAK,OAAO,MAAM,cAAc,CAAC;AAExC,qBACa,0BAA2B,YAAW,sBAAsB,EAAE,8BAA8B;IACvG,SAAS,CAAC,MAAM,EAAE,sBAAsB,GAAG,SAAS,CAAC;IACrD,SAAS,CAAC,oBAAoB,EAAE,eAAe,GAAG,SAAS,CAAC;IAC5D,SAAS,CAAC,eAAe,EAAE,OAAO,CAAC,YAAY,CAAC,YAAY,GAAG,SAAS,CAAC;IAEzE,UAAU,IAAI,IAAI;IAMlB,MAAM,IAAI,IAAI;IAOd,SAAS,CAAC,MAAM,EAAE,sBAAsB,GAAG,SAAS,GAAG,IAAI;IAKrD,iBAAiB,IAAI,OAAO,CAAC,8BAA8B,CAAC;IAS5D,MAAM,IAAI,OAAO,CAAC,OAAO,CAAC;IAK1B,SAAS,IAAI,OAAO,CAAC,OAAO,CAAC;IAI7B,MAAM,IAAI,OAAO,CAAC,OAAO,CAAC;IAI1B,QAAQ,IAAI,OAAO,CAAC,OAAO,CAAC;IAI5B,aAAa,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAI/C,QAAQ,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAI7D,MAAM,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,0BAA0B,GAAG,OAAO,CAAC,OAAO,CAAC;IAI7E,WAAW,CAAC,SAAS,EAAE,MAAM,EAAE,IAAI,EAAE,0BAA0B,GAAG,OAAO,CAAC,IAAI,CAAC;IAI/E,OAAO,CAAC,SAAS,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,gBAAgB,SAAK,GAAG,OAAO,CAAC,OAAO,CAAC;IAIpF,KAAK,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAI1C,IAAI,CAAC,SAAS,EAAE,MAAM,EAAE,SAAS,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAI7D,IAAI,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC;IAIzC,mBAAmB,CACvB,SAAS,EAAE,MAAM,EACjB,YAAY,EAAE,MAAM,EACpB,QAAQ,EAAE,MAAM,EAChB,QAAQ,CAAC,EAAE,OAAO,GACjB,OAAO,CAAC,OAAO,CAAC;cAIH,qBAAqB,IAAI,OAAO,CAAC,IAAI,CAAC;IAkCtD,SAAS,CAAC,kBAAkB,IAAI,IAAI;IAmBpC,SAAS,CAAC,YAAY;IAQtB,SAAS,KAAK,aAAa,WAE1B;IAED,SAAS,KAAK,IAAI,WAGjB;IAED,SAAS,KAAK,OAAO,WAEpB;IAED,SAAS,KAAK,QAAQ,WAErB;IAED,SAAS,KAAK,QAAQ,WAErB;IAED,SAAS,KAAK,SAAS,YAEtB;CACF"} \ No newline at end of file diff --git a/theia-extensions/gitzone-opencode/lib/node/gitzone-opencode-node-service.js b/theia-extensions/gitzone-opencode/lib/node/gitzone-opencode-node-service.js index b57b557..4510353 100644 --- a/theia-extensions/gitzone-opencode/lib/node/gitzone-opencode-node-service.js +++ b/theia-extensions/gitzone-opencode/lib/node/gitzone-opencode-node-service.js @@ -38,7 +38,9 @@ let GitZoneOpenCodeNodeService = class GitZoneOpenCodeNodeService { eventAbortController; openCodeProcess; initialize() { - void this.ensureOpenCodeStarted(); + if (this.autoStart) { + void this.ensureOpenCodeStarted(); + } } onStop() { this.eventAbortController?.abort(); @@ -119,6 +121,13 @@ let GitZoneOpenCodeNodeService = class GitZoneOpenCodeNodeService { stdio: ['ignore', 'ignore', 'ignore'], windowsHide: true, }); + this.openCodeProcess.once('error', (error) => { + console.warn(`OpenCode server autostart failed: ${error.message}`); + this.openCodeProcess = undefined; + }); + this.openCodeProcess.once('exit', () => { + this.openCodeProcess = undefined; + }); } restartEventStream() { this.eventAbortController?.abort(); diff --git a/theia-extensions/gitzone-opencode/lib/node/gitzone-opencode-node-service.js.map b/theia-extensions/gitzone-opencode/lib/node/gitzone-opencode-node-service.js.map index a1bf889..2ac6761 100644 --- a/theia-extensions/gitzone-opencode/lib/node/gitzone-opencode-node-service.js.map +++ b/theia-extensions/gitzone-opencode/lib/node/gitzone-opencode-node-service.js.map @@ -1 +1 @@ -{"version":3,"file":"gitzone-opencode-node-service.js","sourceRoot":"","sources":["../../src/node/gitzone-opencode-node-service.ts"],"names":[],"mappings":"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"} \ No newline at end of file +{"version":3,"file":"gitzone-opencode-node-service.js","sourceRoot":"","sources":["../../src/node/gitzone-opencode-node-service.ts"],"names":[],"mappings":"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"} \ No newline at end of file diff --git a/theia-extensions/gitzone-opencode/src/node/gitzone-opencode-node-service.ts b/theia-extensions/gitzone-opencode/src/node/gitzone-opencode-node-service.ts index 8c77ef9..df59031 100644 --- a/theia-extensions/gitzone-opencode/src/node/gitzone-opencode-node-service.ts +++ b/theia-extensions/gitzone-opencode/src/node/gitzone-opencode-node-service.ts @@ -16,7 +16,9 @@ export class GitZoneOpenCodeNodeService implements IGitZoneOpenCodeServer, Backe protected openCodeProcess: plugins.childProcess.ChildProcess | undefined; initialize(): void { - void this.ensureOpenCodeStarted(); + if (this.autoStart) { + void this.ensureOpenCodeStarted(); + } } onStop(): void { @@ -123,6 +125,13 @@ export class GitZoneOpenCodeNodeService implements IGitZoneOpenCodeServer, Backe windowsHide: true, }, ); + this.openCodeProcess.once('error', (error) => { + console.warn(`OpenCode server autostart failed: ${error.message}`); + this.openCodeProcess = undefined; + }); + this.openCodeProcess.once('exit', () => { + this.openCodeProcess = undefined; + }); } protected restartEventStream(): void {