400 lines
16 KiB
TypeScript
400 lines
16 KiB
TypeScript
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<string, string>;
|
|
|
|
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<string, string>;
|
|
|
|
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, '');
|