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; 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 interface IRemoteOpenCodeToolCommandOptions { runtimeRoot: string; workspacePath: string; toolName: string; nodePath?: string; rgPath?: 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, GITZONE_IDE_OPENCODE_PORT: `${options.opencodePort}`, GITZONE_IDE_DISABLE_OPENCODE_AUTOSTART: '1', 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 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, GITZONE_IDE_OPENCODE_PORT: `${options.opencodePort}`, GITZONE_IDE_DISABLE_OPENCODE_AUTOSTART: '1', 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)}`, `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}`, `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 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 createRemoteOpenCodeToolCommand = (options: IRemoteOpenCodeToolCommandOptions) => { const nodePath = options.nodePath ?? joinRemotePath(options.runtimeRoot, 'node/bin/node'); const rgPath = options.rgPath ?? joinRemotePath(options.runtimeRoot, 'applications/remote-theia/lib/backend/native/rg'); return [ 'set -euo pipefail', `export LD_LIBRARY_PATH=${quoteRemotePath(joinRemotePath(options.runtimeRoot, 'node/lib'))}:\${LD_LIBRARY_PATH:-}`, `export GITZONE_IDE_WORKSPACE=${quoteRemotePath(options.workspacePath)}`, `export GITZONE_IDE_TOOL_NAME=${quoteShellArg(options.toolName)}`, `export GITZONE_IDE_RG_PATH=${quoteRemotePath(rgPath)}`, `${quoteRemotePath(nodePath)} -e ${quoteShellArg(remoteOpenCodeToolScript)}`, ].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 remoteOpenCodeToolScript = [ "const fs = require('fs');", "const path = require('path');", "const childProcess = require('child_process');", "const toolName = process.env.GITZONE_IDE_TOOL_NAME;", "const workspacePath = expandHome(process.env.GITZONE_IDE_WORKSPACE || process.cwd());", "const rgPath = process.env.GITZONE_IDE_RG_PATH || 'rg';", "const inputText = fs.readFileSync(0, 'utf8');", "const request = inputText.trim() ? JSON.parse(inputText) : {};", "const args = request.args || {};", "const MAX_LINES = 2000;", "const MAX_LINE_LENGTH = 2000;", "const MAX_BYTES = 50 * 1024;", "function expandHome(value) {", " const text = String(value || '');", " if (text === '~' || text === '$HOME') return process.env.HOME || text;", " if (text.startsWith('~/')) return path.join(process.env.HOME || '', text.slice(2));", " if (text.startsWith('$HOME/')) return path.join(process.env.HOME || '', text.slice(6));", " return text;", "}", "function normalizeRemotePath(value, fallback) {", " if (!value) return fallback;", " const expanded = expandHome(value);", " return path.isAbsolute(expanded) ? path.normalize(expanded) : path.resolve(fallback, expanded);", "}", "function limitLine(line) {", " return line.length > MAX_LINE_LENGTH ? line.slice(0, MAX_LINE_LENGTH) + '... (line truncated to 2000 chars)' : line;", "}", "function writeJson(result) { process.stdout.write(JSON.stringify(result)); }", "function ensureParent(filePath) { fs.mkdirSync(path.dirname(filePath), { recursive: true }); }", "function toLines(text) {", " if (!text) return [];", " const lines = text.split(/\\r?\\n/);", " if (text.endsWith('\\n')) lines.pop();", " return lines;", "}", "function readTool() {", " const filePath = normalizeRemotePath(args.filePath, workspacePath);", " const stat = fs.statSync(filePath);", " const offset = Math.max(1, Number(args.offset || 1));", " const limit = Math.max(0, Number(args.limit || MAX_LINES));", " if (stat.isDirectory()) {", " const entries = fs.readdirSync(filePath, { withFileTypes: true }).map((entry) => entry.name + (entry.isDirectory() ? '/' : '')).sort((a, b) => a.localeCompare(b));", " const sliced = entries.slice(offset - 1, offset - 1 + limit);", " const truncated = offset - 1 + sliced.length < entries.length;", " return { output: ['' + filePath + '', 'directory', '', sliced.join('\\n'), truncated ? '\\n(Showing ' + sliced.length + ' of ' + entries.length + ' entries. Use offset=' + (offset + sliced.length) + ' to continue.)' : '\\n(' + entries.length + ' entries)', ''].join('\\n'), metadata: { preview: sliced.slice(0, 20).join('\\n'), truncated } };", " }", " const buffer = fs.readFileSync(filePath);", " if (buffer.includes(0)) throw new Error('Cannot read binary file: ' + filePath);", " const lines = toLines(buffer.toString('utf8'));", " if (offset > lines.length && !(lines.length === 0 && offset === 1)) throw new Error('Offset ' + offset + ' is out of range for this file (' + lines.length + ' lines)');", " const raw = [];", " let bytes = 0;", " let cut = false;", " for (const line of lines.slice(offset - 1)) {", " if (raw.length >= limit) break;", " const next = limitLine(line);", " const size = Buffer.byteLength(next, 'utf8') + (raw.length > 0 ? 1 : 0);", " if (bytes + size > MAX_BYTES) { cut = true; break; }", " raw.push(next);", " bytes += size;", " }", " const last = offset + raw.length - 1;", " const more = cut || last < lines.length;", " let output = '' + filePath + '\\nfile\\n\\n';", " output += raw.map((line, index) => (index + offset) + ': ' + line).join('\\n');", " output += more ? '\\n\\n(Showing lines ' + offset + '-' + last + ' of ' + lines.length + '. Use offset=' + (last + 1) + ' to continue.)' : '\\n\\n(End of file - total ' + lines.length + ' lines)';", " output += '\\n';", " return { output, metadata: { preview: raw.slice(0, 20).join('\\n'), truncated: more } };", "}", "function writeTool() {", " const filePath = normalizeRemotePath(args.filePath, workspacePath);", " const existed = fs.existsSync(filePath);", " ensureParent(filePath);", " fs.writeFileSync(filePath, String(args.content || ''));", " return { output: 'Wrote file successfully.', metadata: { filepath: filePath, exists: existed } };", "}", "function editTool() {", " const filePath = normalizeRemotePath(args.filePath, workspacePath);", " if (args.oldString === args.newString) throw new Error('No changes to apply: oldString and newString are identical.');", " let content = fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : '';", " let next;", " if (args.oldString === '') {", " next = String(args.newString || '');", " } else {", " const oldString = String(args.oldString || '');", " const newString = String(args.newString || '');", " const matches = content.split(oldString).length - 1;", " if (matches === 0) throw new Error('Could not find oldString in the file. It must match exactly.');", " if (!args.replaceAll && matches > 1) throw new Error('Found multiple matches for oldString. Provide more surrounding context or set replaceAll.');", " next = args.replaceAll ? content.split(oldString).join(newString) : content.replace(oldString, newString);", " }", " ensureParent(filePath);", " fs.writeFileSync(filePath, next);", " return { output: 'Edit applied successfully.', metadata: { filepath: filePath } };", "}", "function bashTool() {", " const cwd = normalizeRemotePath(args.workdir, workspacePath);", " const timeout = Number(args.timeout || 120000);", " const shell = process.env.SHELL || '/bin/sh';", " const result = childProcess.spawnSync(shell, ['-lc', String(args.command || '')], { cwd, encoding: 'utf8', timeout, maxBuffer: 10 * 1024 * 1024 });", " const outputText = [result.stdout || '', result.stderr || ''].filter(Boolean).join('');", " const metadata = [];", " if (result.error && result.error.code === 'ETIMEDOUT') metadata.push('remote shell tool terminated command after exceeding timeout ' + timeout + ' ms.');", " metadata.push('exit=' + (result.status === null || result.status === undefined ? 1 : result.status));", " const output = (outputText.trim() ? outputText.replace(/\\s+$/g, '') : '(no output)') + '\\n\\n\\n' + metadata.join('\\n') + '\\n';", " return { output, metadata: { exit: result.status, cwd } };", "}", "function grepTool() {", " if (!args.pattern) throw new Error('pattern is required');", " const search = normalizeRemotePath(args.path, workspacePath);", " const executable = fs.existsSync(rgPath) ? rgPath : 'rg';", " const rgArgs = ['--line-number', '--with-filename', '--color', 'never', '--no-heading'];", " if (args.include) rgArgs.push('--glob', String(args.include));", " rgArgs.push('--', String(args.pattern), search);", " const result = childProcess.spawnSync(executable, rgArgs, { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 });", " if (result.status !== 0 && result.status !== 1) throw new Error(result.stderr || ('ripgrep failed with exit ' + result.status));", " const rows = (result.stdout || '').split(/\\r?\\n/).filter(Boolean).map((line) => { const match = line.match(/^(.*?):(\\d+):(.*)$/); if (!match) return undefined; const filePath = path.resolve(match[1]); let mtime = 0; try { mtime = fs.statSync(filePath).mtimeMs; } catch {} return { path: filePath, line: Number(match[2]), text: match[3], mtime }; }).filter(Boolean);", " rows.sort((left, right) => right.mtime - left.mtime);", " const limit = 100;", " const truncated = rows.length > limit;", " const finalRows = truncated ? rows.slice(0, limit) : rows;", " if (finalRows.length === 0) return { output: 'No files found', metadata: { matches: 0, truncated: false } };", " const output = ['Found ' + rows.length + ' matches' + (truncated ? ' (showing first ' + limit + ')' : '')];", " let current = '';", " for (const row of finalRows) { if (current !== row.path) { if (current) output.push(''); current = row.path; output.push(row.path + ':'); } output.push(' Line ' + row.line + ': ' + limitLine(row.text)); }", " if (truncated) output.push('', '(Results truncated: showing ' + limit + ' of ' + rows.length + ' matches.)');", " return { output: output.join('\\n'), metadata: { matches: rows.length, truncated } };", "}", "function globTool() {", " if (!args.pattern) throw new Error('pattern is required');", " const search = normalizeRemotePath(args.path, workspacePath);", " const executable = fs.existsSync(rgPath) ? rgPath : 'rg';", " const result = childProcess.spawnSync(executable, ['--files', '--color', 'never', '--glob', String(args.pattern), '--', search], { encoding: 'utf8', maxBuffer: 10 * 1024 * 1024 });", " if (result.status !== 0 && result.status !== 1) throw new Error(result.stderr || ('ripgrep failed with exit ' + result.status));", " const files = (result.stdout || '').split(/\\r?\\n/).filter(Boolean).map((entry) => path.isAbsolute(entry) ? entry : path.resolve(search, entry)).map((filePath) => { let mtime = 0; try { mtime = fs.statSync(filePath).mtimeMs; } catch {} return { path: filePath, mtime }; }).sort((left, right) => right.mtime - left.mtime);", " const limit = 100;", " const truncated = files.length > limit;", " const finalFiles = truncated ? files.slice(0, limit) : files;", " const output = finalFiles.length ? finalFiles.map((file) => file.path) : ['No files found'];", " if (truncated) output.push('', '(Results are truncated: showing first ' + limit + ' results.)');", " return { output: output.join('\\n'), metadata: { count: finalFiles.length, truncated } };", "}", "function parsePatch(text) {", " const lines = String(text || '').replace(/\\r\\n/g, '\\n').replace(/\\r/g, '\\n').split('\\n');", " if (lines[0] !== '*** Begin Patch') throw new Error('apply_patch verification failed: missing begin marker');", " const ops = [];", " let index = 1;", " while (index < lines.length) {", " const line = lines[index];", " if (line === '*** End Patch') break;", " if (line.startsWith('*** Add File: ')) { const file = line.slice(14); const body = []; index++; while (index < lines.length && !lines[index].startsWith('*** ')) { if (!lines[index].startsWith('+')) throw new Error('add file lines must start with +'); body.push(lines[index].slice(1)); index++; } ops.push({ type: 'add', file, body }); continue; }", " if (line.startsWith('*** Delete File: ')) { ops.push({ type: 'delete', file: line.slice(17) }); index++; continue; }", " if (line.startsWith('*** Update File: ')) { const file = line.slice(17); const body = []; let moveTo; index++; if (lines[index] && lines[index].startsWith('*** Move to: ')) { moveTo = lines[index].slice(13); index++; } while (index < lines.length && !lines[index].startsWith('*** ')) { body.push(lines[index]); index++; } ops.push({ type: 'update', file, moveTo, body }); continue; }", " throw new Error('apply_patch verification failed: unknown patch line ' + line);", " }", " if (ops.length === 0) throw new Error('patch rejected: empty patch');", " return ops;", "}", "function replaceHunk(content, oldLines, newLines) {", " const oldText = oldLines.join('\\n');", " const newText = newLines.join('\\n');", " const candidates = oldLines.length === 0 ? [['', newText]] : [[oldText + '\\n', newText + '\\n'], [oldText, newText]];", " for (const pair of candidates) { const from = pair[0]; const to = pair[1]; const position = from === '' ? content.length : content.indexOf(from); if (position !== -1) return content.slice(0, position) + to + content.slice(position + from.length); }", " throw new Error('apply_patch verification failed: hunk context not found');", "}", "function applyPatchTool() {", " const ops = parsePatch(args.patchText);", " const summary = [];", " for (const op of ops) {", " const filePath = normalizeRemotePath(op.file, workspacePath);", " if (op.type === 'add') { ensureParent(filePath); fs.writeFileSync(filePath, op.body.join('\\n') + (op.body.length ? '\\n' : '')); summary.push('A ' + path.relative(workspacePath, filePath)); continue; }", " if (op.type === 'delete') { fs.rmSync(filePath, { force: true }); summary.push('D ' + path.relative(workspacePath, filePath)); continue; }", " let content = fs.readFileSync(filePath, 'utf8');", " const groups = []; let current = [];", " for (const line of op.body) { if (line.startsWith('@@')) { if (current.length) groups.push(current); current = []; continue; } current.push(line); }", " if (current.length) groups.push(current);", " for (const group of groups) { const oldLines = []; const newLines = []; for (const line of group) { if (!line) continue; const marker = line[0]; const value = line.slice(1); if (marker === ' ') { oldLines.push(value); newLines.push(value); } else if (marker === '-') { oldLines.push(value); } else if (marker === '+') { newLines.push(value); } else if (line.startsWith('\\\\ No newline')) { } else { throw new Error('apply_patch verification failed: invalid hunk line ' + line); } } content = replaceHunk(content, oldLines, newLines); }", " const targetPath = op.moveTo ? normalizeRemotePath(op.moveTo, workspacePath) : filePath;", " ensureParent(targetPath); fs.writeFileSync(targetPath, content); if (op.moveTo) fs.rmSync(filePath, { force: true }); summary.push('M ' + path.relative(workspacePath, targetPath));", " }", " return { output: 'Success. Updated the following files:\\n' + summary.join('\\n'), metadata: { files: summary } };", "}", "try {", " const handlers = { bash: bashTool, read: readTool, write: writeTool, edit: editTool, grep: grepTool, glob: globTool, apply_patch: applyPatchTool };", " if (!handlers[toolName]) throw new Error('Unsupported Git.Zone OpenCode tool: ' + toolName);", " writeJson(handlers[toolName]());", "} catch (error) {", " console.error(error && error.stack ? error.stack : String(error));", " process.exit(1);", "}", ].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, '');