From 61f6d37960c0b405d66da5f7ea12691328c47a39 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Sun, 10 May 2026 22:48:11 +0000 Subject: [PATCH] Add SSH launcher and cached remote runtime --- .../electron-shell/electron-builder.yml | 1 + applications/electron-shell/package.json | 4 +- applications/electron-shell/preload.cjs | 12 + .../electron-shell/scripts/run-electron.mjs | 264 +++++++ applications/electron-shell/ts/main.ts | 676 ++++++++++++++++-- package.json | 1 + packages/server-installer/ts/index.ts | 142 +++- packages/ssh/ts/index.ts | 370 +++++++++- packages/ssh/ts/plugins.ts | 3 +- test/test.installer.node.ts | 74 +- test/test.ssh.node.ts | 68 +- 11 files changed, 1513 insertions(+), 102 deletions(-) create mode 100644 applications/electron-shell/preload.cjs create mode 100644 applications/electron-shell/scripts/run-electron.mjs diff --git a/applications/electron-shell/electron-builder.yml b/applications/electron-shell/electron-builder.yml index 7812b74..7f490a0 100644 --- a/applications/electron-shell/electron-builder.yml +++ b/applications/electron-shell/electron-builder.yml @@ -4,6 +4,7 @@ directories: output: dist files: - dist_ts/**/* + - preload.cjs - package.json linux: target: diff --git a/applications/electron-shell/package.json b/applications/electron-shell/package.json index 4292b2f..16f6fa3 100644 --- a/applications/electron-shell/package.json +++ b/applications/electron-shell/package.json @@ -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"] } diff --git a/applications/electron-shell/preload.cjs b/applications/electron-shell/preload.cjs new file mode 100644 index 0000000..4bb9cb9 --- /dev/null +++ b/applications/electron-shell/preload.cjs @@ -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); + }, +}); diff --git a/applications/electron-shell/scripts/run-electron.mjs b/applications/electron-shell/scripts/run-electron.mjs new file mode 100644 index 0000000..0bd8a74 --- /dev/null +++ b/applications/electron-shell/scripts/run-electron.mjs @@ -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); +}); diff --git a/applications/electron-shell/ts/main.ts b/applications/electron-shell/ts/main.ts index 9d1947e..935463c 100644 --- a/applications/electron-shell/ts/main.ts +++ b/applications/electron-shell/ts/main.ts @@ -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 => { + 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, filePath: string) => { + await new Promise((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 = () => ` Git.Zone IDE -
-

Git.Zone IDE

-

Connect to an SSH host and open a remote Theia workspace powered by OpenCode server.

- - - - - - - - - -

+    
+
Git.Zone IDE
+
+ + +
+
Connect to SSH Host
+
+
+

Connect to Remote Workspace

+

Use system OpenSSH config, agent, keys, ProxyJump, and hardware-key behavior. Saved hosts are written to ~/.ssh/config.

+
Host
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
Workspace Runtime
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+
Output
+

+          
+
+
+
ReadyOpenSSH
`; diff --git a/package.json b/package.json index 38933e7..bf99a3c 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "onlyBuiltDependencies": [ "@vscode/ripgrep", "drivelist", + "electron", "keytar" ], "overrides": { diff --git a/packages/server-installer/ts/index.ts b/packages/server-installer/ts/index.ts index b565cb8..b9b5ff4 100644 --- a/packages/server-installer/ts/index.ts +++ b/packages/server-installer/ts/index.ts @@ -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; + + 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) { diff --git a/packages/ssh/ts/index.ts b/packages/ssh/ts/index.ts index a97e4c1..13e007f 100644 --- a/packages/ssh/ts/index.ts +++ b/packages/ssh/ts/index.ts @@ -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; } +export interface ISshHostSaveInput { + alias: string; + hostName: string; + user?: string; + port?: number; + identityFile?: string; + proxyJump?: string; + forwardAgent?: boolean; +} + const directiveAliases: Record = { 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) => { + 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 => { const executable = options.executable ?? 'ssh'; const args = buildSshArgs(target, remoteCommand, options); + const env = withSshAgentEnv(options.env ?? process.env); return new Promise((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 => { + 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((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++) { diff --git a/packages/ssh/ts/plugins.ts b/packages/ssh/ts/plugins.ts index 68dd01d..1e124d0 100644 --- a/packages/ssh/ts/plugins.ts +++ b/packages/ssh/ts/plugins.ts @@ -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 }; diff --git a/test/test.installer.node.ts b/test/test.installer.node.ts index c913b14..25e2169 100644 --- a/test/test.installer.node.ts +++ b/test/test.installer.node.ts @@ -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(); diff --git a/test/test.ssh.node.ts b/test/test.ssh.node.ts index f0e42bf..a8338e1 100644 --- a/test/test.ssh.node.ts +++ b/test/test.ssh.node.ts @@ -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();