Add SSH launcher and cached remote runtime
This commit is contained in:
@@ -4,6 +4,7 @@ directories:
|
||||
output: dist
|
||||
files:
|
||||
- dist_ts/**/*
|
||||
- preload.cjs
|
||||
- package.json
|
||||
linux:
|
||||
target:
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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">>_</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>`;
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
"onlyBuiltDependencies": [
|
||||
"@vscode/ripgrep",
|
||||
"drivelist",
|
||||
"electron",
|
||||
"keytar"
|
||||
],
|
||||
"overrides": {
|
||||
|
||||
@@ -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
@@ -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++) {
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user