Add SSH launcher and cached remote runtime

This commit is contained in:
2026-05-10 22:48:11 +00:00
parent 138eea3231
commit 61f6d37960
11 changed files with 1513 additions and 102 deletions
@@ -4,6 +4,7 @@ directories:
output: dist
files:
- dist_ts/**/*
- preload.cjs
- package.json
linux:
target:
+2 -2
View File
@@ -6,7 +6,7 @@
"main": "dist_ts/main.js",
"scripts": {
"build": "tsc -p tsconfig.json",
"start": "pnpm run build && electron dist_ts/main.js",
"start": "pnpm run build && node scripts/run-electron.mjs",
"package": "pnpm run build && electron-builder --config electron-builder.yml"
},
"dependencies": {
@@ -18,5 +18,5 @@
"devDependencies": {
"electron-builder": "^26.8.1"
},
"files": ["dist_ts/**/*", "electron-builder.yml"]
"files": ["dist_ts/**/*", "preload.cjs", "electron-builder.yml"]
}
+12
View File
@@ -0,0 +1,12 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('gitZoneIde', {
listHosts: () => ipcRenderer.invoke('gitzone:list-hosts'),
saveHost: (input) => ipcRenderer.invoke('gitzone:save-host', input),
connect: (input) => ipcRenderer.invoke('gitzone:connect', input),
onConnectProgress: (callback) => {
const listener = (_event, message) => callback(message);
ipcRenderer.on('gitzone:connect-progress', listener);
return () => ipcRenderer.removeListener('gitzone:connect-progress', listener);
},
});
@@ -0,0 +1,264 @@
import * as childProcess from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
const packageDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const workspaceDir = path.resolve(packageDir, '../..');
const electronBin = path.join(workspaceDir, 'node_modules', '.bin', 'electron');
const electronEntry = path.join(packageDir, 'dist_ts', 'main.js');
const getUid = () => process.getuid?.() ?? 1000;
const isSocket = (filePath) => {
if (!filePath) {
return false;
}
try {
return fs.statSync(filePath).isSocket();
} catch {
return false;
}
};
const parseAgentEnv = () => {
const agentEnvPath = path.join(os.homedir(), '.ssh', 'agent.env');
try {
const agentEnvText = fs.readFileSync(agentEnvPath, 'utf8');
const socketMatch = agentEnvText.match(/(?:^|\n)SSH_AUTH_SOCK=([^;\n]+)/);
return socketMatch?.[1];
} catch {
return undefined;
}
};
const findTmpAgentSocket = () => {
let candidates = [];
try {
const tmpEntries = fs.readdirSync('/tmp', { withFileTypes: true });
for (const entry of tmpEntries) {
if (!entry.isDirectory() || !entry.name.startsWith('ssh-')) {
continue;
}
const directory = path.join('/tmp', entry.name);
try {
for (const socketEntry of fs.readdirSync(directory, { withFileTypes: true })) {
if (!socketEntry.name.startsWith('agent.')) {
continue;
}
const socketPath = path.join(directory, socketEntry.name);
if (isSocket(socketPath)) {
candidates.push({ path: socketPath, mtimeMs: fs.statSync(socketPath).mtimeMs });
}
}
} catch {}
}
} catch {}
candidates = candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
return candidates[0]?.path;
};
const resolveSshAuthSock = (env) => {
if (isSocket(env.SSH_AUTH_SOCK)) {
return env.SSH_AUTH_SOCK;
}
const agentEnvSocket = parseAgentEnv();
if (isSocket(agentEnvSocket)) {
return agentEnvSocket;
}
return findTmpAgentSocket();
};
const commandExists = (command) => {
try {
childProcess.execFileSync('which', [command], { stdio: 'ignore' });
return true;
} catch {
return false;
}
};
const isSwayRunning = () => {
try {
childProcess.execFileSync('pgrep', ['-x', 'sway'], { stdio: 'ignore' });
return true;
} catch {
return false;
}
};
const getWaylandSocket = () => {
const runtimeDir = process.env.XDG_RUNTIME_DIR || `/run/user/${getUid()}`;
try {
return fs.readdirSync(runtimeDir).find((file) => file.startsWith('wayland-'));
} catch {
return undefined;
}
};
const removeStaleWaylandSocket = (socket) => {
if (!socket || isSwayRunning()) {
return;
}
const runtimeDir = process.env.XDG_RUNTIME_DIR || `/run/user/${getUid()}`;
for (const file of [socket, `${socket}.lock`]) {
try {
fs.unlinkSync(path.join(runtimeDir, file));
} catch {}
}
};
const detectLinuxDisplay = () => {
if (process.env.WAYLAND_DISPLAY) {
return { type: 'wayland', socket: process.env.WAYLAND_DISPLAY };
}
if (process.env.DISPLAY) {
return { type: 'x11' };
}
const existingSocket = getWaylandSocket();
if (existingSocket) {
if (isSwayRunning()) {
return { type: 'wayland', socket: existingSocket };
}
removeStaleWaylandSocket(existingSocket);
}
try {
const driDevices = fs.readdirSync('/dev/dri');
if (driDevices.some((device) => device.startsWith('card'))) {
return { type: 'drm' };
}
} catch {}
return { type: 'none' };
};
const startSway = async () => {
if (!commandExists('sway')) {
throw new Error('No display is available and sway is not installed.');
}
const runtimeDir = process.env.XDG_RUNTIME_DIR || `/run/user/${getUid()}`;
const configDir = path.join(runtimeDir, 'gitzone-ide-sway');
const configPath = path.join(configDir, 'config');
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(
configPath,
['default_border none', 'default_floating_border none', 'hide_edge_borders both', ''].join('\n'),
);
console.log('Starting Sway for Git.Zone IDE...');
const swayProcess = childProcess.spawn('sway', ['--unsupported-gpu', '-c', configPath], {
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
XDG_RUNTIME_DIR: runtimeDir,
XDG_SESSION_TYPE: 'wayland',
LIBSEAT_BACKEND: process.env.LIBSEAT_BACKEND || 'seatd',
WLR_LIBINPUT_NO_DEVICES: '1',
},
});
let stderr = '';
swayProcess.stderr?.on('data', (chunk) => {
stderr += chunk.toString();
});
const startedAt = Date.now();
while (Date.now() - startedAt < 10000) {
const socket = getWaylandSocket();
if (socket) {
console.log(`Sway started on ${socket}.`);
return { process: swayProcess, socket };
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
try {
swayProcess.kill('SIGTERM');
} catch {}
throw new Error(`Sway did not start within 10s.${stderr ? `\n${stderr}` : ''}`);
};
const stopChildProcess = (child) => {
if (!child || child.killed) {
return;
}
try {
child.kill('SIGTERM');
} catch {}
};
const launchElectron = (args, env, swayProcess) => {
const electronProcess = childProcess.spawn(electronBin, args, {
cwd: packageDir,
stdio: 'inherit',
env,
});
const cleanup = () => stopChildProcess(swayProcess);
process.once('SIGINT', () => {
cleanup();
electronProcess.kill('SIGINT');
process.exit(0);
});
process.once('SIGTERM', () => {
cleanup();
electronProcess.kill('SIGTERM');
process.exit(0);
});
electronProcess.on('exit', (code, signal) => {
cleanup();
process.exit(code ?? (signal ? 1 : 0));
});
};
const main = async () => {
const electronArgs = [electronEntry, '--no-sandbox', ...process.argv.slice(2)];
const electronEnv = { ...process.env };
let swayProcess;
electronEnv.GITZONE_IDE_NODE_BINARY = process.execPath;
const sshAuthSock = resolveSshAuthSock(electronEnv);
if (sshAuthSock) {
electronEnv.SSH_AUTH_SOCK = sshAuthSock;
console.log(`Using SSH agent socket: ${sshAuthSock}`);
} else {
console.warn('No SSH agent socket found. System ssh will only use identity files and default keys.');
}
if (os.platform() === 'linux') {
const display = detectLinuxDisplay();
console.log(`Electron display mode: ${display.type}`);
if (display.type === 'wayland') {
electronEnv.WAYLAND_DISPLAY = display.socket;
electronEnv.XDG_RUNTIME_DIR = electronEnv.XDG_RUNTIME_DIR || `/run/user/${getUid()}`;
electronArgs.push('--ozone-platform=wayland');
} else if (display.type === 'drm') {
const sway = await startSway();
swayProcess = sway.process;
electronEnv.WAYLAND_DISPLAY = sway.socket;
electronEnv.XDG_RUNTIME_DIR = electronEnv.XDG_RUNTIME_DIR || `/run/user/${getUid()}`;
electronEnv.RUNTIME_ENV = 'sway';
electronArgs.push('--ozone-platform=wayland');
console.log('Git.Zone IDE is available on the local Sway display.');
} else if (display.type === 'none') {
throw new Error('No display is available. Start X11/Wayland, install sway, or run under xvfb-run.');
}
}
launchElectron(electronArgs, electronEnv, swayProcess);
};
main().catch((error) => {
console.error(error.stack || error.message || String(error));
process.exit(1);
});
+597 -79
View File
@@ -1,14 +1,25 @@
import type { IIdeSshTarget } from '@git.zone/ide-protocol';
import * as childProcess from 'node:child_process';
import { constants as fsConstants, createReadStream } from 'node:fs';
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import * as plugins from './plugins.js';
const defaultRemoteTheiaPort = 33990;
const defaultOpenCodePort = 4096;
const electronDistDir = path.dirname(fileURLToPath(import.meta.url));
const electronPackageRoot = path.resolve(electronDistDir, '..');
const workspaceRoot = path.resolve(electronPackageRoot, '../..');
const runtimeMarkerFileName = plugins.ideServerInstaller.remoteEphemeralRuntimeMarkerFileName;
class GitZoneIdeElectronShell {
private readonly tunnels: plugins.ideSsh.ISshTunnelHandle[] = [];
async start() {
await plugins.electron.app.whenReady();
console.log(`Git.Zone IDE SSH_AUTH_SOCK: ${process.env.SSH_AUTH_SOCK || 'not set'}`);
this.registerIpcHandlers();
const remoteUrl = getArgValue('--remote-url');
@@ -33,50 +44,108 @@ class GitZoneIdeElectronShell {
private registerIpcHandlers() {
plugins.electron.ipcMain.handle('gitzone:list-hosts', async () => {
const hosts = await plugins.ideSsh.readSshConfig();
return plugins.ideSsh.listConnectableHosts(hosts).map((host) => ({
alias: host.alias,
hostName: host.hostName,
user: host.user,
port: host.port,
}));
return {
configPath: plugins.ideSsh.defaultSshConfigPath(),
hosts: plugins.ideSsh.listConnectableHosts(hosts).map(toLauncherHost),
};
});
plugins.electron.ipcMain.handle('gitzone:connect', async (_event, input: IConnectInput) => {
plugins.electron.ipcMain.handle('gitzone:save-host', async (_event, input: ISaveHostInput) => {
const host = await plugins.ideSsh.saveSshHostConfig({
alias: requireTrimmed(input.alias, 'Host alias'),
hostName: requireTrimmed(input.hostName, 'HostName'),
user: trimOptional(input.user),
port: normalizeOptionalPort(input.port, 'Port'),
identityFile: trimOptional(input.identityFile),
proxyJump: trimOptional(input.proxyJump),
forwardAgent: input.forwardAgent,
});
return toLauncherHost(host);
});
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: IIdeSshTarget = {
id: input.hostAlias,
hostAlias: input.hostAlias,
workspacePath: input.workspacePath,
};
const target = createSshTarget(input);
progress(`Preparing SSH connection to ${target.hostAlias}.`);
const opencodePassword = plugins.crypto.randomBytes(24).toString('base64url');
const remoteTheiaPort = input.remoteTheiaPort ?? defaultRemoteTheiaPort;
const opencodePort = input.openCodePort ?? defaultOpenCodePort;
const bootstrapCommand = plugins.ideServerInstaller.createRemoteBootstrapCommand({
serverVersion: plugins.electron.app.getVersion(),
workspacePath: input.workspacePath,
theiaPort: remoteTheiaPort,
opencodePort,
opencodeUsername: 'opencode',
opencodePassword,
});
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 for ${runtime.remoteRoot}.`);
try {
progress('Checking remote runtime cache.');
const cacheCheckCommand = plugins.ideServerInstaller.createRemoteEphemeralRuntimeCacheCheckCommand({
runtimeRoot: runtime.remoteRoot,
runtimeSha256: runtime.contentHash,
});
const cacheCheckResult = await plugins.ideSsh.runSshCommand(target, cacheCheckCommand, {
timeoutMs: 30000,
batchMode: input.batchMode ?? true,
});
if (cacheCheckResult.exitCode === 0) {
progress('Remote runtime cache hit; skipping upload.');
} else {
progress('Remote runtime cache miss; uploading payload.');
const uploadResult = await plugins.ideSsh.uploadDirectoryToRemote(target, runtime.localRoot, runtime.remoteRoot, {
timeoutMs: 300000,
batchMode: input.batchMode ?? true,
});
if (uploadResult.exitCode !== 0) {
throw new Error(uploadResult.stderr || `Remote runtime upload failed with ${uploadResult.exitCode}`);
}
progress('Remote runtime upload complete.');
}
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('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 };
} finally {
await fs.rm(runtime.localRoot, { recursive: true, force: true });
}
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}`;
this.openWorkspaceWindow(url);
return { url, localPort, remoteTheiaPort, opencodePort };
});
}
@@ -86,10 +155,14 @@ class GitZoneIdeElectronShell {
height: 720,
title: 'Git.Zone IDE',
webPreferences: {
contextIsolation: false,
nodeIntegration: true,
contextIsolation: true,
nodeIntegration: false,
preload: fileURLToPath(new URL('../preload.cjs', import.meta.url)),
},
});
window.webContents.on('console-message', (_event, _level, message) => {
console.log(`[launcher] ${message}`);
});
await window.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(renderLauncherHtml())}`);
}
@@ -107,14 +180,235 @@ class GitZoneIdeElectronShell {
}
}
interface IConnectInput {
hostAlias: string;
interface ILauncherHost {
alias: string;
hostName?: string;
user?: string;
port?: number;
identityFiles: string[];
proxyJump?: string;
forwardAgent?: boolean;
}
interface ISaveHostInput {
alias?: string;
hostName?: string;
user?: string;
port?: number;
identityFile?: string;
proxyJump?: string;
forwardAgent?: boolean;
}
interface IConnectInput extends ISaveHostInput {
hostAlias?: string;
workspacePath: string;
remoteTheiaPort?: number;
openCodePort?: number;
batchMode?: boolean;
}
const toLauncherHost = (host: plugins.ideSsh.ISshHostConfig): ILauncherHost => ({
alias: host.alias,
hostName: host.hostName,
user: host.user,
port: host.port,
identityFiles: host.identityFiles,
proxyJump: host.proxyJump,
forwardAgent: host.forwardAgent,
});
const createSshTarget = (input: IConnectInput): IIdeSshTarget => {
const hostAlias = trimOptional(input.hostAlias) || trimOptional(input.hostName);
if (!hostAlias) {
throw new Error('Choose a saved host or enter a HostName.');
}
return {
id: hostAlias,
hostAlias,
hostName: trimOptional(input.hostName),
user: trimOptional(input.user),
port: normalizeOptionalPort(input.port, 'Port'),
workspacePath: requireTrimmed(input.workspacePath, 'Workspace path'),
};
};
const requireTrimmed = (value: string | undefined, label: string) => {
const trimmedValue = trimOptional(value);
if (!trimmedValue) {
throw new Error(`${label} is required.`);
}
return trimmedValue;
};
const trimOptional = (value: string | undefined) => {
const trimmedValue = value?.trim();
return trimmedValue || undefined;
};
const normalizeOptionalPort = (value: number | undefined, label: string) => {
if (value === undefined || value === null || value === 0) {
return undefined;
}
const port = Number(value);
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
throw new Error(`${label} must be a number from 1 to 65535.`);
}
return port;
};
const createProgressEmitter = (webContents: { isDestroyed(): boolean; send(channel: string, ...args: unknown[]): void }) => {
return (message: string) => {
console.log(`[connect] ${message}`);
if (!webContents.isDestroyed()) {
webContents.send('gitzone:connect-progress', message);
}
};
};
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 targetLib = path.join(localRoot, 'applications', 'remote-theia', 'lib');
const nodeBinary = await resolveLocalNodeBinary();
const targetNodeBinary = path.join(localRoot, 'node', 'bin', 'node');
await fs.rm(localRoot, { recursive: true, force: true });
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.mkdir(path.dirname(targetNodeBinary), { recursive: true });
await fs.copyFile(nodeBinary, targetNodeBinary);
await fs.chmod(targetNodeBinary, 0o755);
await copyNodeSharedLibraries(nodeBinary, path.join(localRoot, 'node', 'lib'));
const contentHash = await hashLocalRuntimeDirectory(localRoot);
await fs.writeFile(path.join(localRoot, runtimeMarkerFileName), `${contentHash}\n`, 'utf8');
const remoteRoot = `/tmp/gitzone-ide-${sanitizeRuntimePart(serverVersion)}-${contentHash}`;
return { localRoot, remoteRoot, contentHash };
};
const copyNodeSharedLibraries = async (nodeBinary: string, targetDirectory: string) => {
const lddOutput = childProcess.execFileSync('ldd', [nodeBinary], { encoding: 'utf8' });
const requiredLibraries = new Set(['libatomic.so.1']);
const libraryPaths = lddOutput
.split(/\r?\n/)
.map((line) => line.match(/=>\s+(\/\S+)/)?.[1])
.filter((libraryPath): libraryPath is string => !!libraryPath && requiredLibraries.has(path.basename(libraryPath)));
if (libraryPaths.length === 0) {
return;
}
await fs.mkdir(targetDirectory, { recursive: true });
for (const libraryPath of libraryPaths) {
await fs.copyFile(libraryPath, path.join(targetDirectory, path.basename(libraryPath)));
}
};
const hashLocalRuntimeDirectory = async (rootDirectory: string) => {
const hash = plugins.crypto.createHash('sha256');
const filePaths = await listLocalRuntimeFiles(rootDirectory);
for (const filePath of filePaths) {
const relativePath = path.relative(rootDirectory, filePath).split(path.sep).join('/');
if (relativePath === runtimeMarkerFileName) {
continue;
}
const stats = await fs.lstat(filePath);
if (stats.isSymbolicLink()) {
const linkTarget = await fs.readlink(filePath);
hash.update(`link\0${relativePath}\0${linkTarget}\0`);
continue;
}
if (!stats.isFile()) {
continue;
}
hash.update(`file\0${relativePath}\0${stats.mode & 0o111 ? 'x' : '-'}\0${stats.size}\0`);
await updateHashFromFile(hash, filePath);
hash.update('\0');
}
return hash.digest('hex');
};
const listLocalRuntimeFiles = async (rootDirectory: string) => {
const filePaths: string[] = [];
const walk = async (directory: string): Promise<void> => {
const entries = await fs.readdir(directory, { withFileTypes: true });
for (const entry of entries) {
const filePath = path.join(directory, entry.name);
if (entry.isDirectory()) {
await walk(filePath);
} else if (entry.isFile() || entry.isSymbolicLink()) {
filePaths.push(filePath);
}
}
};
await walk(rootDirectory);
return filePaths.sort((left, right) => path.relative(rootDirectory, left).localeCompare(path.relative(rootDirectory, right)));
};
const updateHashFromFile = async (hash: ReturnType<typeof plugins.crypto.createHash>, filePath: string) => {
await new Promise<void>((resolve, reject) => {
const stream = createReadStream(filePath);
stream.on('data', (chunk) => {
hash.update(chunk);
});
stream.on('error', reject);
stream.on('end', resolve);
});
};
const resolveLocalNodeBinary = async () => {
const candidates = [
process.env.GITZONE_IDE_NODE_BINARY,
process.env.NODE_BINARY,
'/usr/bin/node',
].filter(Boolean) as string[];
for (const candidate of candidates) {
try {
await fs.access(candidate, fsConstants.X_OK);
return candidate;
} catch {}
}
throw new Error('No local Node.js binary found to upload for the remote ephemeral runtime.');
};
const sanitizeRuntimePart = (value: string) => value.replace(/[^a-zA-Z0-9._-]/g, '-');
const waitForHttpUrl = async (url: string, timeoutMs: number) => {
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(url, { 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 tunnel did not become ready at ${url}: ${lastError instanceof Error ? lastError.message : String(lastError)}`);
};
const getArgValue = (name: string) => {
const index = process.argv.indexOf(name);
if (index === -1) {
@@ -129,56 +423,280 @@ const renderLauncherHtml = () => `<!doctype html>
<meta charset="utf-8" />
<title>Git.Zone IDE</title>
<style>
body { margin: 0; font: 14px system-ui, sans-serif; background: #111827; color: #f9fafb; }
main { max-width: 720px; margin: 72px auto; padding: 32px; background: #1f2937; border-radius: 16px; }
h1 { margin-top: 0; font-size: 32px; }
label { display: block; margin-top: 18px; color: #d1d5db; }
input, select, button { width: 100%; box-sizing: border-box; margin-top: 8px; padding: 12px; border-radius: 10px; border: 1px solid #4b5563; background: #111827; color: #f9fafb; }
button { margin-top: 24px; background: #22c55e; border: 0; color: #052e16; font-weight: 700; cursor: pointer; }
pre { white-space: pre-wrap; color: #fca5a5; }
* { box-sizing: border-box; }
:root { color-scheme: dark; --activity: #333333; --side: #252526; --editor: #1e1e1e; --panel: #181818; --border: #3c3c3c; --input: #3c3c3c; --text: #cccccc; --muted: #8f8f8f; --blue: #007acc; --blue-hover: #0e639c; --green: #89d185; }
body { margin: 0; overflow: auto; font: 13px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: var(--editor); color: var(--text); }
button, input, select { font: inherit; }
button { height: 30px; padding: 0 12px; border: 1px solid transparent; color: #ffffff; background: #3a3d41; cursor: pointer; }
button:hover { background: #45494e; }
button.primary { background: var(--blue); }
button.primary:hover { background: var(--blue-hover); }
button.secondary { background: #2d2d2d; border-color: #454545; }
button.warning { background: #4d3b16; color: #ffcc66; border-color: #725516; }
input, select { width: 100%; height: 32px; padding: 5px 8px; border: 1px solid var(--border); background: var(--input); color: #f0f0f0; outline: none; }
input:focus, select:focus { border-color: var(--blue); }
label { display: block; margin: 13px 0 5px; color: #bbbbbb; font-size: 12px; }
p { color: var(--muted); line-height: 1.45; }
.workbench { min-width: 900px; height: 100vh; display: grid; grid-template-rows: 30px minmax(0, 1fr) 22px; }
.titlebar { display: flex; align-items: center; gap: 10px; padding: 0 12px; background: #3c3c3c; color: #cccccc; user-select: none; }
.titlebar-title { font-size: 12px; }
.content { min-height: 0; display: grid; grid-template-columns: 48px 285px minmax(0, 1fr); }
.activitybar { background: var(--activity); border-right: 1px solid #2b2b2b; display: flex; flex-direction: column; align-items: center; padding-top: 12px; }
.activity-icon { width: 28px; height: 28px; display: grid; place-items: center; margin-bottom: 10px; color: #ffffff; border-left: 2px solid #ffffff; font-size: 17px; }
.sidebar { min-width: 0; background: var(--side); border-right: 1px solid var(--border); display: flex; flex-direction: column; }
.sidebar-title { padding: 11px 12px 8px; color: #bbbbbb; font-size: 11px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; }
.sidebar-toolbar { display: flex; gap: 6px; padding: 0 10px 10px; }
.sidebar-toolbar button { flex: 1; height: 26px; padding: 0 8px; font-size: 12px; }
.host-select { margin: 0 10px 8px; width: calc(100% - 20px); height: 28px; background: #2d2d2d; }
.host-list { min-height: 0; overflow: auto; padding-bottom: 8px; }
.host-item { width: 100%; height: auto; min-height: 42px; display: block; padding: 7px 12px; border: 0; border-left: 2px solid transparent; background: transparent; color: var(--text); text-align: left; }
.host-item:hover { background: #2a2d2e; }
.host-item.active { background: #37373d; border-left-color: var(--blue); }
.host-name { display: block; color: #eeeeee; }
.host-detail { display: block; margin-top: 2px; color: var(--muted); font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.empty { display: none; margin: 8px 10px; padding: 10px; border: 1px dashed #555555; color: #a6a6a6; background: #1f1f1f; }
.editor { min-width: 0; min-height: 0; background: var(--editor); display: grid; grid-template-rows: 35px minmax(0, 1fr) 118px; }
.tabs { display: flex; align-items: end; background: #252526; border-bottom: 1px solid var(--border); }
.tab { height: 35px; display: flex; align-items: center; padding: 0 14px; background: var(--editor); border-right: 1px solid var(--border); color: #ffffff; }
.editor-body { overflow: auto; padding: 28px 34px 34px; }
.welcome { max-width: 760px; }
h1 { margin: 0 0 6px; font-size: 24px; font-weight: 400; color: #ffffff; }
.subtitle { margin: 0 0 24px; color: #9d9d9d; }
.section-title { margin: 22px 0 8px; color: #ffffff; font-size: 13px; font-weight: 700; }
.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px 14px; }
.full { grid-column: 1 / -1; }
.actions { display: flex; gap: 8px; margin-top: 18px; flex-wrap: wrap; }
.hint { margin: 8px 0 0; color: #8f8f8f; font-size: 12px; }
.path { color: #c586c0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
.panel { background: var(--panel); border-top: 1px solid var(--border); display: grid; grid-template-rows: 28px minmax(0, 1fr); }
.panel-title { display: flex; align-items: center; padding: 0 12px; color: #cccccc; font-size: 11px; font-weight: 700; text-transform: uppercase; border-bottom: 1px solid #2a2a2a; }
pre { margin: 0; padding: 10px 12px; overflow: auto; white-space: pre-wrap; color: #d4d4d4; font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; }
.statusbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 0 10px; background: var(--blue); color: #ffffff; font-size: 12px; }
@media (max-width: 860px) { .workbench { height: auto; min-height: 100vh; } .grid { grid-template-columns: 1fr; } .editor-body { padding: 20px; } }
</style>
</head>
<body>
<main>
<h1>Git.Zone IDE</h1>
<p>Connect to an SSH host and open a remote Theia workspace powered by OpenCode server.</p>
<label>SSH Host</label>
<select id="host"></select>
<label>Remote Workspace Path</label>
<input id="workspace" value="$HOME" />
<label>Remote Theia Port</label>
<input id="theiaPort" value="${defaultRemoteTheiaPort}" />
<label>OpenCode Port</label>
<input id="opencodePort" value="${defaultOpenCodePort}" />
<button id="connect">Connect</button>
<pre id="output"></pre>
<main class="workbench">
<div class="titlebar"><span class="titlebar-title">Git.Zone IDE</span></div>
<div class="content">
<nav class="activitybar"><div class="activity-icon">&gt;_</div></nav>
<aside class="sidebar">
<div class="sidebar-title">SSH Explorer</div>
<div class="sidebar-toolbar">
<button id="refreshHosts" class="secondary">Refresh</button>
<button id="clearForm" class="secondary">New</button>
</div>
<select id="savedHost" class="host-select"></select>
<div id="hostList" class="host-list"></div>
<div id="emptyState" class="empty">No SSH hosts found. Add one in the connection editor or connect once with HostName.</div>
</aside>
<section class="editor">
<div class="tabs"><div class="tab">Connect to SSH Host</div></div>
<div class="editor-body">
<div class="welcome">
<h1>Connect to Remote Workspace</h1>
<p class="subtitle">Use system OpenSSH config, agent, keys, ProxyJump, and hardware-key behavior. Saved hosts are written to <span id="configPath" class="path">~/.ssh/config</span>.</p>
<div class="section-title">Host</div>
<div class="grid">
<div>
<label>Alias</label>
<input id="alias" placeholder="dev-box" />
</div>
<div>
<label>HostName</label>
<input id="hostName" placeholder="dev.example.com or 192.168.1.20" />
</div>
<div>
<label>User</label>
<input id="user" placeholder="root, ubuntu, philkunz" />
</div>
<div>
<label>Port</label>
<input id="port" type="number" min="1" max="65535" placeholder="22" />
</div>
<div>
<label>IdentityFile</label>
<input id="identityFile" placeholder="~/.ssh/id_ed25519" />
</div>
<div>
<label>ProxyJump</label>
<input id="proxyJump" placeholder="bastion" />
</div>
</div>
<div class="section-title">Workspace Runtime</div>
<div class="grid">
<div class="full">
<label>Remote Workspace Path</label>
<input id="workspace" value="$HOME" />
</div>
<div>
<label>Remote Theia Port</label>
<input id="theiaPort" type="number" min="1" max="65535" value="${defaultRemoteTheiaPort}" />
</div>
<div>
<label>OpenCode Port</label>
<input id="opencodePort" type="number" min="1" max="65535" value="${defaultOpenCodePort}" />
</div>
</div>
<div class="actions">
<button id="connect" class="primary">Connect</button>
<button id="saveHost" class="secondary">Save Host</button>
</div>
</div>
</div>
<div class="panel">
<div class="panel-title">Output</div>
<pre id="output"></pre>
</div>
</section>
</div>
<div class="statusbar"><span id="statusText">Ready</span><span>OpenSSH</span></div>
</main>
<script>
const { ipcRenderer } = require('electron');
const hostSelect = document.getElementById('host');
const output = document.getElementById('output');
ipcRenderer.invoke('gitzone:list-hosts').then((hosts) => {
const ideApi = window.gitZoneIde;
const elements = {
savedHost: document.getElementById('savedHost'),
hostList: document.getElementById('hostList'),
emptyState: document.getElementById('emptyState'),
configPath: document.getElementById('configPath'),
alias: document.getElementById('alias'),
hostName: document.getElementById('hostName'),
user: document.getElementById('user'),
port: document.getElementById('port'),
identityFile: document.getElementById('identityFile'),
proxyJump: document.getElementById('proxyJump'),
workspace: document.getElementById('workspace'),
theiaPort: document.getElementById('theiaPort'),
opencodePort: document.getElementById('opencodePort'),
output: document.getElementById('output'),
statusText: document.getElementById('statusText'),
};
let hosts = [];
const optionalNumber = (value) => value ? Number(value) : undefined;
const selectedHost = () => hosts.find((host) => host.alias === elements.savedHost.value);
const setOutput = (message) => {
elements.output.textContent = message;
elements.statusText.textContent = message.split('\\n')[0] || 'Ready';
};
const appendOutput = (message) => {
const nextLine = '[' + new Date().toLocaleTimeString() + '] ' + message;
elements.output.textContent = elements.output.textContent ? elements.output.textContent + '\\n' + nextLine : nextLine;
elements.output.scrollTop = elements.output.scrollHeight;
elements.statusText.textContent = message;
};
if (ideApi.onConnectProgress) {
ideApi.onConnectProgress((message) => appendOutput(String(message)));
}
const formPayload = () => ({
hostAlias: elements.alias.value.trim() || undefined,
alias: elements.alias.value.trim() || undefined,
hostName: elements.hostName.value.trim() || undefined,
user: elements.user.value.trim() || undefined,
port: optionalNumber(elements.port.value),
identityFile: elements.identityFile.value.trim() || undefined,
proxyJump: elements.proxyJump.value.trim() || undefined,
workspacePath: elements.workspace.value.trim(),
remoteTheiaPort: optionalNumber(elements.theiaPort.value),
openCodePort: optionalNumber(elements.opencodePort.value),
});
const renderHosts = () => {
elements.savedHost.replaceChildren();
elements.hostList.replaceChildren();
const manualOption = document.createElement('option');
manualOption.value = '';
manualOption.textContent = 'New or manual host';
elements.savedHost.appendChild(manualOption);
for (const host of hosts) {
const option = document.createElement('option');
option.value = host.alias;
option.textContent = host.alias + (host.hostName ? ' (' + host.hostName + ')' : '');
hostSelect.appendChild(option);
}
}).catch((error) => output.textContent = error.stack || String(error));
document.getElementById('connect').addEventListener('click', async () => {
output.textContent = 'Connecting...';
try {
const result = await ipcRenderer.invoke('gitzone:connect', {
hostAlias: hostSelect.value,
workspacePath: document.getElementById('workspace').value,
remoteTheiaPort: Number(document.getElementById('theiaPort').value),
openCodePort: Number(document.getElementById('opencodePort').value),
elements.savedHost.appendChild(option);
const item = document.createElement('button');
item.className = 'host-item' + (elements.savedHost.value === host.alias ? ' active' : '');
item.type = 'button';
item.dataset.alias = host.alias;
const name = document.createElement('span');
name.className = 'host-name';
name.textContent = host.alias;
const detail = document.createElement('span');
detail.className = 'host-detail';
detail.textContent = [host.user, host.hostName || host.alias].filter(Boolean).join('@') + (host.port ? ':' + host.port : '');
item.append(name, detail);
item.addEventListener('click', () => {
elements.savedHost.value = host.alias;
fillHost(host);
renderHosts();
setOutput('Selected Host ' + host.alias + '.');
});
output.textContent = 'Opened ' + result.url;
elements.hostList.appendChild(item);
}
elements.emptyState.style.display = hosts.length ? 'none' : 'block';
};
const fillHost = (host) => {
elements.alias.value = host?.alias || '';
elements.hostName.value = host?.hostName || '';
elements.user.value = host?.user || '';
elements.port.value = host?.port || '';
elements.identityFile.value = host?.identityFiles?.[0] || '';
elements.proxyJump.value = host?.proxyJump || '';
};
const loadHosts = async (preferredAlias) => {
const result = await ideApi.listHosts();
hosts = result.hosts || [];
elements.configPath.textContent = result.configPath || '~/.ssh/config';
renderHosts();
setOutput('Loaded ' + hosts.length + ' SSH host' + (hosts.length === 1 ? '' : 's') + '.');
if (preferredAlias && hosts.some((host) => host.alias === preferredAlias)) {
elements.savedHost.value = preferredAlias;
fillHost(selectedHost());
renderHosts();
}
};
elements.savedHost.addEventListener('change', () => {
fillHost(selectedHost());
renderHosts();
});
document.getElementById('refreshHosts').addEventListener('click', async () => {
try {
await loadHosts(elements.alias.value.trim());
setOutput('SSH hosts refreshed.');
} catch (error) {
output.textContent = error.stack || String(error);
setOutput(error.stack || String(error));
}
});
document.getElementById('clearForm').addEventListener('click', () => {
elements.savedHost.value = '';
fillHost(undefined);
setOutput('Enter host details, then save or connect once.');
});
document.getElementById('saveHost').addEventListener('click', async () => {
setOutput('Saving SSH host...');
try {
const savedHost = await ideApi.saveHost(formPayload());
await loadHosts(savedHost.alias);
setOutput('Saved Host ' + savedHost.alias + ' to OpenSSH config.');
} catch (error) {
setOutput(error.stack || String(error));
}
});
document.getElementById('connect').addEventListener('click', async () => {
setOutput('Connecting...');
try {
const result = await ideApi.connect(formPayload());
appendOutput('Opened ' + result.url);
window.location.assign(result.url);
} catch (error) {
appendOutput(error.stack || String(error));
}
});
loadHosts().catch((error) => setOutput(error.stack || String(error)));
</script>
</body>
</html>`;