import { gitZoneIdeProtocolVersion, type IRemoteServerManifest, type IRemoteServerPaths, } from '@git.zone/ide-protocol'; export interface IRemoteServerInstallPlanOptions { serverVersion: string; artifactName: string; installRoot?: string; platform?: string; arch?: string; sha256?: string; protocolVersion?: number; createdAt?: string; } export interface IRemoteServerInstallPlan { manifest: IRemoteServerManifest; paths: IRemoteServerPaths; markerFile: string; } export interface IRemoteServerBootstrapOptions { serverVersion: string; workspacePath: string; theiaPort: number; installRoot?: string; nodeEnv?: string; theiaColorTheme?: string; } 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 interface IRemoteEphemeralRuntimeMarkOptions { runtimeRoot: string; runtimeSha256: string; markerFileName?: string; nodePath?: string; } export interface IRemoteEphemeralPortAllocationOptions { runtimeRoot: string; count?: number; nodePath?: string; } export interface IRemoteProjectRegistryOptions { runtimeRoot: string; ideDataRoot?: string; nodePath?: string; } export interface IRemoteProjectUpsertOptions extends IRemoteProjectRegistryOptions { projectPath: string; title?: string; } export const defaultIdeDataRoot = '~/.git.zone/ide'; export const defaultInstallRoot = '~/.git.zone/ide/server'; export const remoteEphemeralRuntimeMarkerFileName = '.gitzone-runtime-sha256'; export const remoteProjectsFileName = 'projects.json'; export const defaultTheiaColorTheme = 'dark'; export const createRemoteServerInstallPlan = ( options: IRemoteServerInstallPlanOptions, ): IRemoteServerInstallPlan => { const installRoot = trimTrailingSlash(options.installRoot ?? defaultInstallRoot); const versionRoot = joinRemotePath(installRoot, options.serverVersion); const paths: IRemoteServerPaths = { installRoot, versionRoot, currentLink: joinRemotePath(installRoot, 'current'), logsDir: joinRemotePath(installRoot, 'logs'), manifestPath: joinRemotePath(versionRoot, 'manifest.json'), }; return { manifest: createRemoteServerManifest(options), paths, markerFile: joinRemotePath(versionRoot, '.installed'), }; }; export const createRemoteServerManifest = ( options: IRemoteServerInstallPlanOptions, ): IRemoteServerManifest => ({ protocolVersion: options.protocolVersion ?? gitZoneIdeProtocolVersion, serverVersion: options.serverVersion, platform: options.platform ?? 'unknown', arch: options.arch ?? 'unknown', artifactName: options.artifactName, sha256: options.sha256, createdAt: options.createdAt ?? new Date().toISOString(), }); export const createRemoteInstallCommand = (plan: IRemoteServerInstallPlan) => { const manifestJson = JSON.stringify(plan.manifest, undefined, 2); return [ 'set -euo pipefail', `mkdir -p ${quoteRemotePath(plan.paths.versionRoot)} ${quoteRemotePath(plan.paths.logsDir)}`, `cat > ${quoteRemotePath(plan.paths.manifestPath)} <<'GITZONE_IDE_MANIFEST'`, manifestJson, 'GITZONE_IDE_MANIFEST', `ln -sfn ${quoteRemotePath(plan.paths.versionRoot)} ${quoteRemotePath(plan.paths.currentLink)}`, `touch ${quoteRemotePath(plan.markerFile)}`, ].join('\n'); }; export const createRemoteBootstrapCommand = (options: IRemoteServerBootstrapOptions) => { const plan = createRemoteServerInstallPlan({ serverVersion: options.serverVersion, artifactName: 'remote-theia', installRoot: options.installRoot, }); const appDir = joinRemotePath(plan.paths.versionRoot, 'applications/remote-theia'); const logFile = joinRemotePath(plan.paths.logsDir, `theia-${options.theiaPort}.log`); const env = { GITZONE_IDE_WORKSPACE: options.workspacePath, NODE_ENV: options.nodeEnv ?? 'production', } satisfies Record; return [ 'set -euo pipefail', `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}`, ].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 theiaSettingsPath = joinRemotePath(theiaConfigDir, 'settings.json'); const logFile = joinRemotePath(logsDir, `theia-${options.theiaPort}.log`); const theiaColorTheme = options.theiaColorTheme ?? defaultTheiaColorTheme; const themePreferenceScript = [ "const fs = require('fs');", 'const settingsPath = process.env.GITZONE_IDE_THEIA_SETTINGS;', 'const colorTheme = process.env.GITZONE_IDE_THEIA_COLOR_THEME;', 'let settings = {};', 'try {', " const raw = fs.readFileSync(settingsPath, 'utf8').trim();", ' settings = raw ? JSON.parse(raw) : {};', '} catch (error) {', " if (!error || error.code !== 'ENOENT') throw error;", '}', "settings['workbench.colorTheme'] = colorTheme;", "fs.writeFileSync(settingsPath, `${JSON.stringify(settings, undefined, 2)}\\n`);", ].join('\n'); const env = { GITZONE_IDE_WORKSPACE: options.workspacePath, NODE_ENV: options.nodeEnv ?? 'production', } satisfies Record; return [ 'set -euo pipefail', `mkdir -p ${quoteRemotePath(logsDir)} ${quoteRemotePath(theiaConfigDir)}`, `export LD_LIBRARY_PATH=${quoteRemotePath(joinRemotePath(options.runtimeRoot, 'node/lib'))}:\${LD_LIBRARY_PATH:-}`, `test -x ${quoteRemotePath(nodePath)} || { printf 'bundled node not executable: %s\n' ${quoteShellArg(nodePath)} >&2; exit 1; }`, `test -f ${quoteRemotePath(joinRemotePath(appDir, 'lib/backend/main.js'))} || { printf 'bundled Theia backend missing: %s\n' ${quoteShellArg(appDir)} >&2; exit 1; }`, `test -d ${quoteRemotePath(options.workspacePath)} || { printf 'workspace path not found: %s\n' ${quoteShellArg(options.workspacePath)} >&2; exit 1; }`, `GITZONE_IDE_THEIA_SETTINGS=${quoteRemotePath(theiaSettingsPath)} GITZONE_IDE_THEIA_COLOR_THEME=${quoteShellArg(theiaColorTheme)} ${quoteRemotePath(nodePath)} <<'GITZONE_IDE_THEME'`, themePreferenceScript, 'GITZONE_IDE_THEME', `cd ${quoteRemotePath(options.workspacePath)}`, `export 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}`, ].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 createRemoteEphemeralRuntimeMarkCommand = (options: IRemoteEphemeralRuntimeMarkOptions) => { const markerFileName = options.markerFileName ?? remoteEphemeralRuntimeMarkerFileName; const markerPath = joinRemotePath(options.runtimeRoot, markerFileName); const markerTempPath = `${markerPath}.tmp`; 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 -x ${quoteRemotePath(nodePath)}`, `test -f ${quoteRemotePath(backendPath)}`, `printf '%s\n' ${quoteShellArg(options.runtimeSha256)} > ${quoteRemotePath(markerTempPath)}`, `mv ${quoteRemotePath(markerTempPath)} ${quoteRemotePath(markerPath)}`, `printf 'runtimeCache=stored\n'`, ].join('\n'); }; export const createRemoteEphemeralPortAllocationCommand = (options: IRemoteEphemeralPortAllocationOptions) => { const nodePath = options.nodePath ?? joinRemotePath(options.runtimeRoot, 'node/bin/node'); const count = options.count ?? 1; const script = [ "const net = require('net');", `const count = ${JSON.stringify(count)};`, 'const ports = [];', 'const servers = [];', 'const listen = () => new Promise((resolve, reject) => {', ' const server = net.createServer();', " server.on('error', reject);", " server.listen(0, '127.0.0.1', () => {", ' ports.push(server.address().port);', ' servers.push(server);', ' resolve();', ' });', '});', '(async () => {', ' for (let index = 0; index < count; index++) await listen();', " console.log(`ports=${ports.join(',')}`);", ' await Promise.all(servers.map((server) => new Promise((resolve) => server.close(resolve))));', '})().catch((error) => { console.error(error.stack || String(error)); process.exit(1); });', ].join('\n'); return [ 'set -euo pipefail', `export LD_LIBRARY_PATH=${quoteRemotePath(joinRemotePath(options.runtimeRoot, 'node/lib'))}:\${LD_LIBRARY_PATH:-}`, `${quoteRemotePath(nodePath)} -e ${quoteShellArg(script)}`, ].join('\n'); }; export const createRemoteProjectListCommand = (options: IRemoteProjectRegistryOptions) => { const projectsFile = joinRemotePath(options.ideDataRoot ?? defaultIdeDataRoot, remoteProjectsFileName); return [ 'set -euo pipefail', `if test -f ${quoteRemotePath(projectsFile)}; then`, ` cat ${quoteRemotePath(projectsFile)}`, 'else', " printf '{\"projects\":[]}\n'", 'fi', ].join('\n'); }; export const createRemoteProjectUpsertCommand = (options: IRemoteProjectUpsertOptions) => { const ideDataRoot = options.ideDataRoot ?? defaultIdeDataRoot; const projectsFile = joinRemotePath(ideDataRoot, remoteProjectsFileName); const nodePath = options.nodePath ?? joinRemotePath(options.runtimeRoot, 'node/bin/node'); const script = [ "const crypto = require('crypto');", "const fs = require('fs');", "const path = require('path');", 'const projectsFile = process.env.GITZONE_IDE_PROJECTS_FILE;', 'const projectPath = process.env.GITZONE_IDE_PROJECT_PATH;', 'const title = process.env.GITZONE_IDE_PROJECT_TITLE || path.basename(projectPath) || projectPath;', "let registry = { projects: [] };", "try { registry = JSON.parse(fs.readFileSync(projectsFile, 'utf8')); } catch {}", "if (!Array.isArray(registry.projects)) registry.projects = [];", "const id = crypto.createHash('sha256').update(projectPath).digest('hex').slice(0, 16);", 'const now = new Date().toISOString();', 'const existing = registry.projects.find((project) => project.id === id || project.path === projectPath);', 'if (existing) {', ' existing.id = id;', ' existing.path = projectPath;', ' existing.title = title;', ' existing.updatedAt = now;', '} else {', ' registry.projects.push({ id, path: projectPath, title, createdAt: now, updatedAt: now });', '}', 'registry.projects.sort((left, right) => left.title.localeCompare(right.title));', 'fs.mkdirSync(path.dirname(projectsFile), { recursive: true });', "fs.writeFileSync(`${projectsFile}.tmp`, `${JSON.stringify(registry, undefined, 2)}\\n`);", "fs.renameSync(`${projectsFile}.tmp`, projectsFile);", 'console.log(JSON.stringify(registry));', ].join('\n'); return [ 'set -euo pipefail', `export GITZONE_IDE_PROJECTS_FILE=${quoteRemotePath(projectsFile)}`, `export GITZONE_IDE_PROJECT_PATH=${quoteRemotePath(options.projectPath)}`, `export GITZONE_IDE_PROJECT_TITLE=${quoteShellArg(options.title ?? '')}`, `test -d "$GITZONE_IDE_PROJECT_PATH" || { printf 'workspace path not found: %s\n' "$GITZONE_IDE_PROJECT_PATH" >&2; exit 1; }`, `export LD_LIBRARY_PATH=${quoteRemotePath(joinRemotePath(options.runtimeRoot, 'node/lib'))}:\${LD_LIBRARY_PATH:-}`, `${quoteRemotePath(nodePath)} -e ${quoteShellArg(script)}`, ].join('\n'); }; export const createRemoteHealthCommand = (serverVersion: string, installRoot = defaultInstallRoot) => { const plan = createRemoteServerInstallPlan({ serverVersion, artifactName: 'remote-theia', installRoot, }); return [ 'set -euo pipefail', `test -f ${quoteRemotePath(plan.markerFile)}`, `cat ${quoteRemotePath(plan.paths.manifestPath)}`, ].join('\n'); }; export const quoteShellArg = (value: string | number | boolean) => { const stringValue = String(value); if (stringValue.length === 0) { return "''"; } 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) { return ''; } return [trimTrailingSlash(first), ...rest.map((part) => part.replace(/^\/+|\/+$/g, ''))] .filter(Boolean) .join('/'); }; const trimTrailingSlash = (value: string) => value.replace(/\/+$/g, '');