Support remote project tabs with local OpenCode bridge

Keeps provider credentials local while executing OpenCode shell and file tools against the selected remote workspace over SSH.
This commit is contained in:
2026-05-11 14:28:12 +00:00
parent 1ccf2fb1cf
commit 6f32a206b4
18 changed files with 1793 additions and 194 deletions
+344 -1
View File
@@ -30,6 +30,7 @@ export interface IRemoteServerBootstrapOptions {
opencodePassword: string;
installRoot?: string;
nodeEnv?: string;
theiaColorTheme?: string;
}
export interface IRemoteEphemeralBootstrapOptions extends IRemoteServerBootstrapOptions {
@@ -60,9 +61,36 @@ export interface IRemoteEphemeralRuntimeMarkOptions {
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,
@@ -120,6 +148,7 @@ export const createRemoteBootstrapCommand = (options: IRemoteServerBootstrapOpti
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',
@@ -148,10 +177,27 @@ export const createRemoteEphemeralBootstrapCommand = (options: IRemoteEphemeralB
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',
@@ -160,11 +206,14 @@ export const createRemoteEphemeralBootstrapCommand = (options: IRemoteEphemeralB
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 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);
@@ -233,6 +282,107 @@ export const createRemoteEphemeralRuntimeMarkCommand = (options: IRemoteEphemera
].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,
@@ -246,6 +396,199 @@ export const createRemoteHealthCommand = (serverVersion: string, installRoot = d
].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: ['<path>' + filePath + '</path>', '<type>directory</type>', '<entries>', sliced.join('\\n'), truncated ? '\\n(Showing ' + sliced.length + ' of ' + entries.length + ' entries. Use offset=' + (offset + sliced.length) + ' to continue.)' : '\\n(' + entries.length + ' entries)', '</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 = '<path>' + filePath + '</path>\\n<type>file</type>\\n<content>\\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</content>';",
" 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<shell_metadata>\\n' + metadata.join('\\n') + '\\n</shell_metadata>';",
" 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) {