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);
});
+570 -52
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,26 +44,66 @@ 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(),
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.');
}
progress('Starting remote Theia runtime.');
const bootstrapCommand = plugins.ideServerInstaller.createRemoteEphemeralBootstrapCommand({
serverVersion,
runtimeRoot: runtime.remoteRoot,
workspacePath: input.workspacePath,
theiaPort: remoteTheiaPort,
opencodePort,
@@ -67,7 +118,20 @@ class GitZoneIdeElectronShell {
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,
@@ -75,8 +139,13 @@ class GitZoneIdeElectronShell {
});
this.tunnels.push(tunnel);
const url = `http://127.0.0.1:${localPort}`;
this.openWorkspaceWindow(url);
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 });
}
});
}
@@ -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>
<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" value="${defaultRemoteTheiaPort}" />
<input id="theiaPort" type="number" min="1" max="65535" value="${defaultRemoteTheiaPort}" />
</div>
<div>
<label>OpenCode Port</label>
<input id="opencodePort" value="${defaultOpenCodePort}" />
<button id="connect">Connect</button>
<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);
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 + '.');
});
elements.hostList.appendChild(item);
}
}).catch((error) => output.textContent = error.stack || String(error));
document.getElementById('connect').addEventListener('click', async () => {
output.textContent = 'Connecting...';
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 {
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),
});
output.textContent = 'Opened ' + result.url;
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>`;
+1
View File
@@ -24,6 +24,7 @@
"onlyBuiltDependencies": [
"@vscode/ripgrep",
"drivelist",
"electron",
"keytar"
],
"overrides": {
+130 -12
View File
@@ -32,7 +32,30 @@ export interface IRemoteServerBootstrapOptions {
nodeEnv?: string;
}
export const defaultInstallRoot = '~/.git.zone/ide-server';
export interface IRemoteEphemeralBootstrapOptions extends IRemoteServerBootstrapOptions {
runtimeRoot: string;
ideDataRoot?: string;
nodePath?: string;
}
export interface IRemoteEphemeralReadinessOptions {
runtimeRoot: string;
theiaPort: number;
ideDataRoot?: string;
nodePath?: string;
waitSeconds?: number;
}
export interface IRemoteEphemeralRuntimeCacheCheckOptions {
runtimeRoot: string;
runtimeSha256: string;
markerFileName?: string;
nodePath?: string;
}
export const defaultIdeDataRoot = '~/.git.zone/ide';
export const defaultInstallRoot = '~/.git.zone/ide/server';
export const remoteEphemeralRuntimeMarkerFileName = '.gitzone-runtime-sha256';
export const createRemoteServerInstallPlan = (
options: IRemoteServerInstallPlanOptions,
@@ -70,12 +93,12 @@ export const createRemoteInstallCommand = (plan: IRemoteServerInstallPlan) => {
const manifestJson = JSON.stringify(plan.manifest, undefined, 2);
return [
'set -euo pipefail',
`mkdir -p ${quoteShellArg(plan.paths.versionRoot)} ${quoteShellArg(plan.paths.logsDir)}`,
`cat > ${quoteShellArg(plan.paths.manifestPath)} <<'GITZONE_IDE_MANIFEST'`,
`mkdir -p ${quoteRemotePath(plan.paths.versionRoot)} ${quoteRemotePath(plan.paths.logsDir)}`,
`cat > ${quoteRemotePath(plan.paths.manifestPath)} <<'GITZONE_IDE_MANIFEST'`,
manifestJson,
'GITZONE_IDE_MANIFEST',
`ln -sfn ${quoteShellArg(plan.paths.versionRoot)} ${quoteShellArg(plan.paths.currentLink)}`,
`touch ${quoteShellArg(plan.markerFile)}`,
`ln -sfn ${quoteRemotePath(plan.paths.versionRoot)} ${quoteRemotePath(plan.paths.currentLink)}`,
`touch ${quoteRemotePath(plan.markerFile)}`,
].join('\n');
};
@@ -97,16 +120,95 @@ export const createRemoteBootstrapCommand = (options: IRemoteServerBootstrapOpti
return [
'set -euo pipefail',
`mkdir -p ${quoteShellArg(plan.paths.logsDir)}`,
`test -d ${quoteShellArg(options.workspacePath)}`,
`cd ${quoteShellArg(options.workspacePath)}`,
...Object.entries(env).map(([key, value]) => `export ${key}=${quoteShellArg(value)}`),
`nohup pnpm --dir ${quoteShellArg(appDir)} start --hostname 127.0.0.1 --port ${options.theiaPort} ${quoteShellArg(options.workspacePath)} > ${quoteShellArg(logFile)} 2>&1 < /dev/null &`,
`mkdir -p ${quoteRemotePath(plan.paths.logsDir)}`,
`test -d ${quoteRemotePath(options.workspacePath)} || { printf 'workspace path not found: %s\n' ${quoteShellArg(options.workspacePath)} >&2; exit 1; }`,
`test -d ${quoteRemotePath(appDir)} || { printf 'remote Theia app not installed: %s\n' ${quoteShellArg(appDir)} >&2; exit 1; }`,
'command -v pnpm >/dev/null || { printf \'pnpm not found on remote host\n\' >&2; exit 1; }',
`cd ${quoteRemotePath(options.workspacePath)}`,
...Object.entries(env).map(([key, value]) => {
const renderedValue = key === 'GITZONE_IDE_WORKSPACE' ? quoteRemotePath(value) : quoteShellArg(value);
return `export ${key}=${renderedValue}`;
}),
`nohup pnpm --dir ${quoteRemotePath(appDir)} start --hostname 127.0.0.1 --port ${options.theiaPort} ${quoteRemotePath(options.workspacePath)} > ${quoteRemotePath(logFile)} 2>&1 < /dev/null &`,
`printf 'theiaPort=%s\\n' ${options.theiaPort}`,
`printf 'opencodePort=%s\\n' ${options.opencodePort}`,
].join('\n');
};
export const createRemoteEphemeralBootstrapCommand = (options: IRemoteEphemeralBootstrapOptions) => {
const appDir = joinRemotePath(options.runtimeRoot, 'applications/remote-theia');
const nodePath = options.nodePath ?? joinRemotePath(options.runtimeRoot, 'node/bin/node');
const ideDataRoot = options.ideDataRoot ?? defaultIdeDataRoot;
const logsDir = joinRemotePath(ideDataRoot, 'logs');
const theiaConfigDir = joinRemotePath(ideDataRoot, 'theia');
const logFile = joinRemotePath(logsDir, `theia-${options.theiaPort}.log`);
const env = {
GITZONE_IDE_WORKSPACE: options.workspacePath,
GITZONE_IDE_OPENCODE_PORT: `${options.opencodePort}`,
OPENCODE_SERVER_USERNAME: options.opencodeUsername,
OPENCODE_SERVER_PASSWORD: options.opencodePassword,
NODE_ENV: options.nodeEnv ?? 'production',
} satisfies Record<string, string>;
return [
'set -euo pipefail',
`mkdir -p ${quoteRemotePath(logsDir)} ${quoteRemotePath(theiaConfigDir)}`,
`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; }`,
`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);
return `export ${key}=${renderedValue}`;
}),
`nohup ${quoteRemotePath(nodePath)} ${quoteRemotePath(joinRemotePath(appDir, 'lib/backend/main.js'))} --hostname 127.0.0.1 --port ${options.theiaPort} ${quoteRemotePath(options.workspacePath)} > ${quoteRemotePath(logFile)} 2>&1 < /dev/null &`,
`printf 'runtimeRoot=%s\n' ${quoteShellArg(options.runtimeRoot)}`,
`printf 'theiaPort=%s\n' ${options.theiaPort}`,
`printf 'opencodePort=%s\n' ${options.opencodePort}`,
].join('\n');
};
export const createRemoteEphemeralReadinessCommand = (options: IRemoteEphemeralReadinessOptions) => {
const nodePath = options.nodePath ?? joinRemotePath(options.runtimeRoot, 'node/bin/node');
const ideDataRoot = options.ideDataRoot ?? defaultIdeDataRoot;
const logFile = joinRemotePath(ideDataRoot, 'logs', `theia-${options.theiaPort}.log`);
const waitSeconds = options.waitSeconds ?? 30;
const probeScript = `fetch('http://127.0.0.1:${options.theiaPort}/').then((response) => process.exit(response.ok ? 0 : 1)).catch(() => process.exit(1))`;
return [
'set -euo pipefail',
`export LD_LIBRARY_PATH=${quoteRemotePath(joinRemotePath(options.runtimeRoot, 'node/lib'))}:\${LD_LIBRARY_PATH:-}`,
`for attempt in $(seq 1 ${waitSeconds}); do`,
` if ${quoteRemotePath(nodePath)} -e ${quoteShellArg(probeScript)} >/dev/null 2>&1; then`,
` printf 'theiaReady=%s\n' ${options.theiaPort}`,
' exit 0',
' fi',
' sleep 1',
'done',
`printf 'remote Theia did not become ready on port %s\n' ${options.theiaPort} >&2`,
`if test -f ${quoteRemotePath(logFile)}; then sed -n '1,200p' ${quoteRemotePath(logFile)} >&2; fi`,
'exit 1',
].join('\n');
};
export const createRemoteEphemeralRuntimeCacheCheckCommand = (options: IRemoteEphemeralRuntimeCacheCheckOptions) => {
const markerFileName = options.markerFileName ?? remoteEphemeralRuntimeMarkerFileName;
const markerPath = joinRemotePath(options.runtimeRoot, markerFileName);
const nodePath = options.nodePath ?? joinRemotePath(options.runtimeRoot, 'node/bin/node');
const backendPath = joinRemotePath(options.runtimeRoot, 'applications/remote-theia/lib/backend/main.js');
return [
'set -euo pipefail',
`test -f ${quoteRemotePath(markerPath)}`,
`test "$(cat ${quoteRemotePath(markerPath)})" = ${quoteShellArg(options.runtimeSha256)}`,
`test -x ${quoteRemotePath(nodePath)}`,
`test -f ${quoteRemotePath(backendPath)}`,
`printf 'runtimeCache=hit\n'`,
].join('\n');
};
export const createRemoteHealthCommand = (serverVersion: string, installRoot = defaultInstallRoot) => {
const plan = createRemoteServerInstallPlan({
serverVersion,
@@ -115,8 +217,8 @@ export const createRemoteHealthCommand = (serverVersion: string, installRoot = d
});
return [
'set -euo pipefail',
`test -f ${quoteShellArg(plan.markerFile)}`,
`cat ${quoteShellArg(plan.paths.manifestPath)}`,
`test -f ${quoteRemotePath(plan.markerFile)}`,
`cat ${quoteRemotePath(plan.paths.manifestPath)}`,
].join('\n');
};
@@ -128,6 +230,22 @@ export const quoteShellArg = (value: string | number | boolean) => {
return `'${stringValue.replace(/'/g, `'"'"'`)}'`;
};
export const quoteRemotePath = (value: string | number | boolean) => {
const stringValue = String(value);
if (stringValue === '~' || stringValue === '$HOME' || stringValue === '${HOME}') {
return '"$HOME"';
}
for (const prefix of ['~/', '$HOME/', '${HOME}/']) {
if (stringValue.startsWith(prefix)) {
const suffix = stringValue.slice(prefix.length);
return suffix ? `"$HOME"/${quoteShellArg(suffix)}` : '"$HOME"';
}
}
return quoteShellArg(stringValue);
};
export const joinRemotePath = (...parts: string[]) => {
const [first, ...rest] = parts.filter(Boolean);
if (!first) {
+367 -3
View File
@@ -34,6 +34,10 @@ export interface ISshTunnelOptions extends ISshRunOptions {
remotePort: number;
}
export interface ISshUploadOptions extends ISshRunOptions {
cleanRemote?: boolean;
}
export interface ISshTunnelHandle {
readonly target: IIdeSshTarget;
readonly localHost: string;
@@ -44,6 +48,16 @@ export interface ISshTunnelHandle {
dispose(signal?: NodeJS.Signals): Promise<void>;
}
export interface ISshHostSaveInput {
alias: string;
hostName: string;
user?: string;
port?: number;
identityFile?: string;
proxyJump?: string;
forwardAgent?: boolean;
}
const directiveAliases: Record<string, keyof ISshHostConfig | 'identityFiles'> = {
hostname: 'hostName',
user: 'user',
@@ -105,9 +119,70 @@ export const parseSshConfig = (configText: string): ISshHostConfig[] => {
};
export const readSshConfig = async (filePath = defaultSshConfigPath()) => {
return readSshConfigRecursive(filePath, new Set());
};
export const renderSshHostBlock = (input: ISshHostSaveInput) => {
const host = normalizeSshHostSaveInput(input);
const lines = [`Host ${host.alias}`, ` HostName ${host.hostName}`];
if (host.user) {
lines.push(` User ${host.user}`);
}
if (host.port) {
lines.push(` Port ${host.port}`);
}
if (host.identityFile) {
lines.push(` IdentityFile ${host.identityFile}`);
}
if (host.proxyJump) {
lines.push(` ProxyJump ${host.proxyJump}`);
}
if (host.forwardAgent !== undefined) {
lines.push(` ForwardAgent ${host.forwardAgent ? 'yes' : 'no'}`);
}
return `${lines.join('\n')}\n`;
};
export const saveSshHostConfig = async (
input: ISshHostSaveInput,
filePath = defaultSshConfigPath(),
) => {
const host = normalizeSshHostSaveInput(input);
const expandedPath = expandHome(filePath);
let configText = '';
try {
configText = await plugins.fs.readFile(expandedPath, 'utf8');
} catch (error) {
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code !== 'ENOENT') {
throw error;
}
}
const nextConfigText = upsertSshHostBlock(configText, host);
await plugins.fs.mkdir(plugins.path.dirname(expandedPath), { recursive: true, mode: 0o700 });
await plugins.fs.writeFile(expandedPath, nextConfigText, { mode: 0o600 });
try {
await plugins.fs.chmod(expandedPath, 0o600);
} catch {}
return parseSshConfig(nextConfigText).find((parsedHost) => parsedHost.alias === host.alias)!;
};
const readSshConfigRecursive = async (filePath: string, visitedFiles: Set<string>) => {
const expandedPath = expandHome(filePath);
if (visitedFiles.has(expandedPath)) {
return [];
}
visitedFiles.add(expandedPath);
try {
const configText = await plugins.fs.readFile(expandHome(filePath), 'utf8');
return parseSshConfig(configText);
const hosts = parseSshConfig(configText);
for (const includePath of await resolveSshIncludePaths(configText, plugins.path.dirname(expandedPath))) {
hosts.push(...await readSshConfigRecursive(includePath, visitedFiles));
}
return hosts;
} catch (error) {
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code === 'ENOENT') {
@@ -145,11 +220,12 @@ export const runSshCommand = async (
): Promise<ISshRunResult> => {
const executable = options.executable ?? 'ssh';
const args = buildSshArgs(target, remoteCommand, options);
const env = withSshAgentEnv(options.env ?? process.env);
return new Promise<ISshRunResult>((resolve, reject) => {
const child = plugins.childProcess.spawn(executable, args, {
cwd: options.cwd,
env: options.env ?? process.env,
env,
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
@@ -224,7 +300,7 @@ export const startSshTunnel = (
];
const child = plugins.childProcess.spawn(executable, args, {
cwd: options.cwd,
env: options.env ?? process.env,
env: withSshAgentEnv(options.env ?? process.env),
shell: false,
stdio: ['ignore', 'ignore', 'pipe'],
windowsHide: true,
@@ -259,6 +335,81 @@ export const startSshTunnel = (
};
};
export const uploadDirectoryToRemote = async (
target: IIdeSshTarget,
localDirectory: string,
remoteDirectory: string,
options: ISshUploadOptions = {},
): Promise<ISshRunResult> => {
const remoteCommand = [
'set -euo pipefail',
options.cleanRemote === false ? undefined : `rm -rf ${quoteShellArg(remoteDirectory)}`,
`mkdir -p ${quoteShellArg(remoteDirectory)}`,
`tar -xzf - -C ${quoteShellArg(remoteDirectory)}`,
].filter(Boolean).join('\n');
const sshArgs = buildSshArgs(target, remoteCommand, options);
const env = withSshAgentEnv(options.env ?? process.env);
return new Promise<ISshRunResult>((resolve, reject) => {
const tar = plugins.childProcess.spawn('tar', ['-czf', '-', '-C', localDirectory, '.'], {
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
const ssh = plugins.childProcess.spawn(options.executable ?? 'ssh', sshArgs, {
cwd: options.cwd,
env,
shell: false,
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true,
});
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
let tarStderr = '';
let settled = false;
const timeout = options.timeoutMs
? setTimeout(() => {
tar.kill('SIGTERM');
ssh.kill('SIGTERM');
finish({ exitCode: 1, stdout: '', stderr: `remote upload timed out after ${options.timeoutMs}ms` });
}, options.timeoutMs)
: undefined;
const finish = (result: ISshRunResult) => {
if (settled) {
return;
}
settled = true;
if (timeout) {
clearTimeout(timeout);
}
resolve(result);
};
tar.stdout.pipe(ssh.stdin);
tar.stderr.on('data', (chunk: Buffer) => {
tarStderr += chunk.toString('utf8');
});
tar.on('error', reject);
ssh.on('error', reject);
ssh.stdout.on('data', (chunk: Buffer) => stdout.push(chunk));
ssh.stderr.on('data', (chunk: Buffer) => stderr.push(chunk));
tar.on('close', (exitCode) => {
if (exitCode !== 0) {
ssh.kill('SIGTERM');
finish({ exitCode: exitCode ?? 1, stdout: '', stderr: tarStderr || `tar exited with code ${exitCode}` });
}
});
ssh.on('close', (exitCode) => {
finish({
exitCode: exitCode ?? 1,
stdout: Buffer.concat(stdout).toString('utf8'),
stderr: [tarStderr, Buffer.concat(stderr).toString('utf8')].filter(Boolean).join('\n'),
});
});
});
};
export const probeRemoteHost = async (
target: IIdeSshTarget,
options: ISshRunOptions = {},
@@ -336,12 +487,225 @@ const buildSshOptionArgs = (target: IIdeSshTarget, options: ISshRunOptions = {})
}
args.push('-o', 'ServerAliveInterval=30');
args.push('-o', 'ServerAliveCountMax=3');
if (target.hostName && target.hostName !== target.hostAlias) {
args.push('-o', `HostName=${target.hostName}`);
}
if (target.port) {
args.push('-p', `${target.port}`);
}
return args;
};
const withSshAgentEnv = (env: NodeJS.ProcessEnv) => {
const nextEnv = { ...env };
if (isSocketPath(nextEnv.SSH_AUTH_SOCK)) {
return nextEnv;
}
const agentEnvSocket = readAgentEnvSocket();
if (isSocketPath(agentEnvSocket)) {
nextEnv.SSH_AUTH_SOCK = agentEnvSocket;
return nextEnv;
}
const tmpSocket = findTmpAgentSocket();
if (tmpSocket) {
nextEnv.SSH_AUTH_SOCK = tmpSocket;
}
return nextEnv;
};
const quoteShellArg = (value: string | number | boolean) => {
const stringValue = String(value);
if (stringValue.length === 0) {
return "''";
}
return `'${stringValue.replace(/'/g, `'"'"'`)}'`;
};
const readAgentEnvSocket = () => {
const agentEnvPath = plugins.path.join(plugins.os.homedir(), '.ssh', 'agent.env');
try {
const agentEnvText = plugins.fsSync.readFileSync(agentEnvPath, 'utf8');
return agentEnvText.match(/(?:^|\n)SSH_AUTH_SOCK=([^;\n]+)/)?.[1];
} catch {
return undefined;
}
};
const findTmpAgentSocket = () => {
const candidates: Array<{ path: string; mtimeMs: number }> = [];
try {
for (const tmpEntry of plugins.fsSync.readdirSync('/tmp', { withFileTypes: true })) {
if (!tmpEntry.isDirectory() || !tmpEntry.name.startsWith('ssh-')) {
continue;
}
const directory = plugins.path.join('/tmp', tmpEntry.name);
try {
for (const socketEntry of plugins.fsSync.readdirSync(directory, { withFileTypes: true })) {
if (!socketEntry.name.startsWith('agent.')) {
continue;
}
const socketPath = plugins.path.join(directory, socketEntry.name);
if (isSocketPath(socketPath)) {
candidates.push({ path: socketPath, mtimeMs: plugins.fsSync.statSync(socketPath).mtimeMs });
}
}
} catch {}
}
} catch {}
return candidates.sort((a, b) => b.mtimeMs - a.mtimeMs)[0]?.path;
};
const isSocketPath = (filePath: string | undefined) => {
if (!filePath) {
return false;
}
try {
return plugins.fsSync.statSync(filePath).isSocket();
} catch {
return false;
}
};
const normalizeSshHostSaveInput = (input: ISshHostSaveInput): ISshHostSaveInput => {
const host: ISshHostSaveInput = {
alias: input.alias.trim(),
hostName: input.hostName.trim(),
user: input.user?.trim() || undefined,
port: input.port,
identityFile: input.identityFile?.trim() || undefined,
proxyJump: input.proxyJump?.trim() || undefined,
forwardAgent: input.forwardAgent,
};
validateSshToken(host.alias, 'Host alias');
validateSshToken(host.hostName, 'HostName');
if (host.user) {
validateSshToken(host.user, 'User');
}
if (host.identityFile) {
validateSshToken(host.identityFile, 'IdentityFile');
}
if (host.proxyJump) {
validateSshToken(host.proxyJump, 'ProxyJump');
}
if (host.port !== undefined && (!Number.isInteger(host.port) || host.port <= 0 || host.port > 65535)) {
throw new Error('Port must be a number from 1 to 65535.');
}
return host;
};
const validateSshToken = (value: string, label: string) => {
if (!value) {
throw new Error(`${label} is required.`);
}
if (/\s/.test(value)) {
throw new Error(`${label} must not contain whitespace.`);
}
};
const upsertSshHostBlock = (configText: string, host: ISshHostSaveInput) => {
const blockText = renderSshHostBlock(host).trimEnd();
const lines = configText.split(/\r?\n/);
const hostBlocks = findSshHostBlocks(lines);
const exactBlock = hostBlocks.find((block) => block.patterns.length === 1 && block.patterns[0] === host.alias);
if (exactBlock) {
lines.splice(exactBlock.start, exactBlock.end - exactBlock.start, ...blockText.split('\n'));
return `${trimTrailingBlankLines(lines).join('\n')}\n`;
}
const conflictingBlock = hostBlocks.find((block) => block.patterns.includes(host.alias));
if (conflictingBlock) {
throw new Error(`Host ${host.alias} is part of a multi-host block. Edit ${defaultSshConfigPath()} manually or choose a new alias.`);
}
const trimmedConfig = configText.trimEnd();
if (!trimmedConfig) {
return `${blockText}\n`;
}
return `${trimmedConfig}\n\n${blockText}\n`;
};
const findSshHostBlocks = (lines: string[]) => {
const blocks: Array<{ start: number; end: number; patterns: string[] }> = [];
for (let index = 0; index < lines.length; index++) {
const tokens = tokenizeSshConfigLine(stripSshComment(lines[index]!).trim());
if (tokens[0]?.toLowerCase() !== 'host') {
continue;
}
const nextHostIndex = findNextHostLine(lines, index + 1);
blocks.push({
start: index,
end: nextHostIndex === -1 ? lines.length : nextHostIndex,
patterns: tokens.slice(1),
});
}
return blocks;
};
const findNextHostLine = (lines: string[], startIndex: number) => {
for (let index = startIndex; index < lines.length; index++) {
const tokens = tokenizeSshConfigLine(stripSshComment(lines[index]!).trim());
if (tokens[0]?.toLowerCase() === 'host') {
return index;
}
}
return -1;
};
const trimTrailingBlankLines = (lines: string[]) => {
const nextLines = [...lines];
while (nextLines.length > 0 && !nextLines[nextLines.length - 1]!.trim()) {
nextLines.pop();
}
return nextLines;
};
const resolveSshIncludePaths = async (configText: string, baseDir: string) => {
const includePaths: string[] = [];
for (const rawLine of configText.split(/\r?\n/)) {
const tokens = tokenizeSshConfigLine(stripSshComment(rawLine).trim());
if (tokens[0]?.toLowerCase() !== 'include') {
continue;
}
for (const includePattern of tokens.slice(1)) {
includePaths.push(...await expandSshIncludePattern(includePattern, baseDir));
}
}
return includePaths;
};
const expandSshIncludePattern = async (includePattern: string, baseDir: string) => {
const expandedPattern = expandHome(includePattern);
const absolutePattern = plugins.path.isAbsolute(expandedPattern)
? expandedPattern
: plugins.path.resolve(baseDir, expandedPattern);
const directory = plugins.path.dirname(absolutePattern);
const basename = plugins.path.basename(absolutePattern);
if (!/[?*]/.test(basename)) {
return [absolutePattern];
}
try {
const entries = await plugins.fs.readdir(directory, { withFileTypes: true });
const matcher = wildcardToRegExp(basename);
return entries
.filter((entry) => entry.isFile() && matcher.test(entry.name))
.map((entry) => plugins.path.join(directory, entry.name))
.sort();
} catch {
return [];
}
};
const wildcardToRegExp = (pattern: string) => {
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
return new RegExp(`^${escaped.replace(/\*/g, '.*').replace(/\?/g, '.')}$`);
};
const stripSshComment = (line: string) => {
let quote: string | undefined;
for (let index = 0; index < line.length; index++) {
+2 -1
View File
@@ -1,7 +1,8 @@
import * as childProcess from 'node:child_process';
import * as fs from 'node:fs/promises';
import * as fsSync from 'node:fs';
import * as net from 'node:net';
import * as os from 'node:os';
import * as path from 'node:path';
export { childProcess, fs, net, os, path };
export { childProcess, fs, fsSync, net, os, path };
+70 -4
View File
@@ -1,9 +1,13 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import {
createRemoteEphemeralBootstrapCommand,
createRemoteEphemeralReadinessCommand,
createRemoteEphemeralRuntimeCacheCheckCommand,
createRemoteBootstrapCommand,
createRemoteInstallCommand,
createRemoteServerInstallPlan,
joinRemotePath,
quoteRemotePath,
quoteShellArg,
} from '../packages/server-installer/ts/index.js';
@@ -11,20 +15,21 @@ tap.test('should create stable remote install paths', async () => {
const plan = createRemoteServerInstallPlan({
serverVersion: '0.1.0',
artifactName: 'remote-theia-linux-x64.tgz',
installRoot: '~/.git.zone/ide-server',
platform: 'linux',
arch: 'x64',
});
expect(plan.paths.versionRoot).toEqual('~/.git.zone/ide-server/0.1.0');
expect(plan.paths.currentLink).toEqual('~/.git.zone/ide-server/current');
expect(plan.paths.versionRoot).toEqual('~/.git.zone/ide/server/0.1.0');
expect(plan.paths.currentLink).toEqual('~/.git.zone/ide/server/current');
expect(plan.manifest.protocolVersion).toEqual(1);
expect(plan.manifest.artifactName).toEqual('remote-theia-linux-x64.tgz');
});
tap.test('should quote shell arguments safely', async () => {
expect(quoteShellArg("that's it")).toEqual("'that'\"'\"'s it'");
expect(joinRemotePath('~/.git.zone/', '/ide-server/', '/0.1.0')).toEqual('~/.git.zone/ide-server/0.1.0');
expect(quoteRemotePath('~/.git.zone/ide/server')).toEqual('"$HOME"/\'.git.zone/ide/server\'');
expect(quoteRemotePath('$HOME/work/project')).toEqual('"$HOME"/\'work/project\'');
expect(joinRemotePath('~/.git.zone/', '/ide/', '/0.1.0')).toEqual('~/.git.zone/ide/0.1.0');
});
tap.test('should render install and bootstrap commands', async () => {
@@ -43,8 +48,69 @@ 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('pnpm --dir');
});
tap.test('should render remote home paths as expandable shell paths', async () => {
const bootstrapCommand = createRemoteBootstrapCommand({
serverVersion: '0.1.0',
workspacePath: '$HOME',
theiaPort: 33990,
opencodePort: 4096,
opencodeUsername: 'opencode',
opencodePassword: 'secret',
});
expect(bootstrapCommand).toInclude('test -d "$HOME"');
expect(bootstrapCommand).toInclude('export GITZONE_IDE_WORKSPACE="$HOME"');
expect(bootstrapCommand).toInclude('"$HOME"/\'.git.zone/ide/server/0.1.0/applications/remote-theia\'');
});
tap.test('should render ephemeral runtime bootstrap without remote pnpm', async () => {
const bootstrapCommand = createRemoteEphemeralBootstrapCommand({
serverVersion: '0.1.0',
runtimeRoot: '/tmp/gitzone-ide-runtime-test',
workspacePath: '$HOME',
theiaPort: 33990,
opencodePort: 4096,
opencodeUsername: 'opencode',
opencodePassword: 'secret',
});
expect(bootstrapCommand).toInclude('/tmp/gitzone-ide-runtime-test/node/bin/node');
expect(bootstrapCommand).toInclude('lib/backend/main.js');
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('"$HOME"/\'.git.zone/ide/logs\'');
expect(bootstrapCommand).toInclude('runtimeRoot=');
});
tap.test('should render ephemeral readiness check with remote logs', async () => {
const readinessCommand = createRemoteEphemeralReadinessCommand({
runtimeRoot: '/tmp/gitzone-ide-runtime-test',
theiaPort: 33990,
waitSeconds: 2,
});
expect(readinessCommand).toInclude('fetch');
expect(readinessCommand).toInclude('theia-33990.log');
expect(readinessCommand).toInclude('"$HOME"/\'.git.zone/ide/logs/theia-33990.log\'');
expect(readinessCommand).toInclude('LD_LIBRARY_PATH');
});
tap.test('should render ephemeral runtime cache check command', async () => {
const cacheCheckCommand = createRemoteEphemeralRuntimeCacheCheckCommand({
runtimeRoot: '/tmp/gitzone-ide-0.1.0-deadbeef',
runtimeSha256: 'deadbeef',
});
expect(cacheCheckCommand).toInclude('.gitzone-runtime-sha256');
expect(cacheCheckCommand).toInclude('test "$(cat');
expect(cacheCheckCommand).toInclude("'deadbeef'");
expect(cacheCheckCommand).toInclude('runtimeCache=hit');
});
export default tap.start();
+67 -1
View File
@@ -1,5 +1,14 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { buildSshArgs, listConnectableHosts, parseSshConfig } from '../packages/ssh/ts/index.js';
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
import {
buildSshArgs,
listConnectableHosts,
parseSshConfig,
readSshConfig,
saveSshHostConfig,
} from '../packages/ssh/ts/index.js';
tap.test('should parse ssh config hosts', async () => {
const hosts = parseSshConfig(`
@@ -44,4 +53,61 @@ tap.test('should build ssh args with destination and command', async () => {
expect(args[args.length - 1]).toEqual('uname -a');
});
tap.test('should build ssh args for one-time hostname overrides', async () => {
const args = buildSshArgs(
{
id: 'manual-box',
hostAlias: 'manual-box',
hostName: '192.168.1.20',
user: 'root',
},
'uname -a',
);
expect(args).toContain('-o');
expect(args).toContain('HostName=192.168.1.20');
expect(args).toContain('root@manual-box');
});
tap.test('should save and update ssh host config', async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitzone-ssh-'));
const configPath = path.join(tempDir, '.ssh', 'config');
await saveSshHostConfig({
alias: 'dev-box',
hostName: 'dev.example.com',
user: 'deploy',
port: 22,
identityFile: '~/.ssh/id_ed25519',
}, configPath);
await saveSshHostConfig({
alias: 'dev-box',
hostName: 'dev2.example.com',
user: 'deploy',
port: 2200,
}, configPath);
const configText = await fs.readFile(configPath, 'utf8');
const hosts = parseSshConfig(configText);
expect(hosts).toHaveLength(1);
expect(hosts[0]!.hostName).toEqual('dev2.example.com');
expect(hosts[0]!.port).toEqual(2200);
expect(configText.includes('dev.example.com')).toEqual(false);
});
tap.test('should read included ssh config files', async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gitzone-ssh-'));
const sshDir = path.join(tempDir, '.ssh');
const includeDir = path.join(sshDir, 'config.d');
await fs.mkdir(includeDir, { recursive: true });
await fs.writeFile(path.join(sshDir, 'config'), 'Include config.d/*\n');
await fs.writeFile(path.join(includeDir, 'dev.conf'), 'Host included-box\n HostName included.example.com\n');
const hosts = await readSshConfig(path.join(sshDir, 'config'));
const connectableHosts = listConnectableHosts(hosts);
expect(connectableHosts).toHaveLength(1);
expect(connectableHosts[0]!.alias).toEqual('included-box');
expect(connectableHosts[0]!.hostName).toEqual('included.example.com');
});
export default tap.start();