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; opencodePort: number; opencodeUsername: string; opencodePassword: string; installRoot?: string; nodeEnv?: 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 const defaultIdeDataRoot = '~/.git.zone/ide'; export const defaultInstallRoot = '~/.git.zone/ide/server'; export const remoteEphemeralRuntimeMarkerFileName = '.gitzone-runtime-sha256'; 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, 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(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, 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, '');