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 const defaultInstallRoot = '~/.git.zone/ide-server'; 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 ${quoteShellArg(plan.paths.versionRoot)} ${quoteShellArg(plan.paths.logsDir)}`, `cat > ${quoteShellArg(plan.paths.manifestPath)} <<'GITZONE_IDE_MANIFEST'`, manifestJson, 'GITZONE_IDE_MANIFEST', `ln -sfn ${quoteShellArg(plan.paths.versionRoot)} ${quoteShellArg(plan.paths.currentLink)}`, `touch ${quoteShellArg(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 ${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 &`, `printf 'theiaPort=%s\\n' ${options.theiaPort}`, `printf 'opencodePort=%s\\n' ${options.opencodePort}`, ].join('\n'); }; export const createRemoteHealthCommand = (serverVersion: string, installRoot = defaultInstallRoot) => { const plan = createRemoteServerInstallPlan({ serverVersion, artifactName: 'remote-theia', installRoot, }); return [ 'set -euo pipefail', `test -f ${quoteShellArg(plan.markerFile)}`, `cat ${quoteShellArg(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 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, '');