Initialize remote IDE scaffold

This commit is contained in:
2026-05-10 14:08:25 +00:00
commit 138eea3231
97 changed files with 21129 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
{
"name": "@git.zone/ide-opencode-bridge",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist_ts/index.js",
"typings": "dist_ts/index.d.ts",
"scripts": {
"build": "tsc -p tsconfig.json"
},
"files": [
"ts/**/*",
"dist_ts/**/*"
]
}
+349
View File
@@ -0,0 +1,349 @@
import * as plugins from './plugins.js';
export interface IOpenCodeClientOptions {
baseUrl: string;
username?: string;
password?: string;
fetch?: typeof fetch;
}
export interface IOpenCodeMessagePart {
type: string;
[key: string]: unknown;
}
export interface IOpenCodePromptBody {
messageID?: string;
model?: {
providerID: string;
modelID: string;
};
agent?: string;
noReply?: boolean;
system?: string;
tools?: Record<string, boolean>;
parts: IOpenCodeMessagePart[];
}
export interface IOpenCodeCommandBody {
messageID?: string;
agent?: string;
model?: {
providerID: string;
modelID: string;
};
command: string;
arguments?: string;
}
export interface IOpenCodeShellBody {
agent: string;
model?: {
providerID: string;
modelID: string;
};
command: string;
}
export interface IOpenCodeEvent {
type: string;
id?: string;
retry?: number;
data?: unknown;
raw: string;
}
export class OpenCodeHttpError extends Error {
constructor(
message: string,
public readonly status: number,
public readonly body: string,
) {
super(message);
}
}
export class OpenCodeServerClient {
private readonly baseUrl: string;
private readonly fetchImpl: typeof fetch;
private readonly authorizationHeader?: string;
constructor(options: IOpenCodeClientOptions) {
this.baseUrl = options.baseUrl.replace(/\/+$/g, '');
this.fetchImpl = options.fetch ?? globalThis.fetch.bind(globalThis);
if (options.username || options.password) {
const username = options.username ?? 'opencode';
const password = options.password ?? '';
this.authorizationHeader = `Basic ${plugins.Buffer.from(`${username}:${password}`).toString('base64')}`;
}
}
async health<T = unknown>() {
return this.request<T>('/global/health');
}
async projects<T = unknown>() {
return this.request<T>('/project');
}
async currentProject<T = unknown>() {
return this.request<T>('/project/current');
}
async path<T = unknown>() {
return this.request<T>('/path');
}
async vcs<T = unknown>() {
return this.request<T>('/vcs');
}
async config<T = unknown>() {
return this.request<T>('/config');
}
async providers<T = unknown>() {
return this.request<T>('/provider');
}
async agents<T = unknown>() {
return this.request<T>('/agent');
}
async commands<T = unknown>() {
return this.request<T>('/command');
}
async sessions<T = unknown>() {
return this.request<T>('/session');
}
async createSession<T = unknown>(body: { parentID?: string; title?: string } = {}) {
return this.request<T>('/session', { method: 'POST', body });
}
async session<T = unknown>(id: string) {
return this.request<T>(`/session/${encodeURIComponent(id)}`);
}
async updateSession<T = unknown>(id: string, body: { title?: string }) {
return this.request<T>(`/session/${encodeURIComponent(id)}`, { method: 'PATCH', body });
}
async deleteSession<T = unknown>(id: string) {
return this.request<T>(`/session/${encodeURIComponent(id)}`, { method: 'DELETE' });
}
async sessionStatus<T = unknown>() {
return this.request<T>('/session/status');
}
async children<T = unknown>(id: string) {
return this.request<T>(`/session/${encodeURIComponent(id)}/children`);
}
async todo<T = unknown>(id: string) {
return this.request<T>(`/session/${encodeURIComponent(id)}/todo`);
}
async messages<T = unknown>(id: string, limit?: number) {
const query = limit ? `?limit=${encodeURIComponent(`${limit}`)}` : '';
return this.request<T>(`/session/${encodeURIComponent(id)}/message${query}`);
}
async message<T = unknown>(id: string, messageID: string) {
return this.request<T>(`/session/${encodeURIComponent(id)}/message/${encodeURIComponent(messageID)}`);
}
async prompt<T = unknown>(id: string, body: IOpenCodePromptBody) {
return this.request<T>(`/session/${encodeURIComponent(id)}/message`, { method: 'POST', body });
}
async promptAsync(id: string, body: IOpenCodePromptBody) {
return this.request<void>(`/session/${encodeURIComponent(id)}/prompt_async`, { method: 'POST', body });
}
async command<T = unknown>(id: string, body: IOpenCodeCommandBody) {
return this.request<T>(`/session/${encodeURIComponent(id)}/command`, { method: 'POST', body });
}
async shell<T = unknown>(id: string, body: IOpenCodeShellBody) {
return this.request<T>(`/session/${encodeURIComponent(id)}/shell`, { method: 'POST', body });
}
async abort<T = unknown>(id: string) {
return this.request<T>(`/session/${encodeURIComponent(id)}/abort`, { method: 'POST' });
}
async diff<T = unknown>(id: string, messageID?: string) {
const query = messageID ? `?messageID=${encodeURIComponent(messageID)}` : '';
return this.request<T>(`/session/${encodeURIComponent(id)}/diff${query}`);
}
async revert<T = unknown>(id: string, body: { messageID: string; partID?: string }) {
return this.request<T>(`/session/${encodeURIComponent(id)}/revert`, { method: 'POST', body });
}
async unrevert<T = unknown>(id: string) {
return this.request<T>(`/session/${encodeURIComponent(id)}/unrevert`, { method: 'POST' });
}
async respondToPermission<T = unknown>(
sessionID: string,
permissionID: string,
body: { response: string; remember?: boolean },
) {
return this.request<T>(
`/session/${encodeURIComponent(sessionID)}/permissions/${encodeURIComponent(permissionID)}`,
{ method: 'POST', body },
);
}
async findFiles<T = unknown>(query: { query: string; type?: 'file' | 'directory'; directory?: string; limit?: number }) {
return this.request<T>(`/find/file?${new URLSearchParams(stringifyQuery(query))}`);
}
async findText<T = unknown>(query: { pattern: string }) {
return this.request<T>(`/find?${new URLSearchParams(stringifyQuery(query))}`);
}
async fileContent<T = unknown>(path: string) {
return this.request<T>(`/file/content?${new URLSearchParams({ path })}`);
}
async fileStatus<T = unknown>() {
return this.request<T>('/file/status');
}
async *events(signal?: AbortSignal): AsyncGenerator<IOpenCodeEvent> {
const response = await this.fetchImpl(`${this.baseUrl}/event`, {
headers: this.createHeaders(),
signal,
});
if (!response.ok) {
throw await this.createHttpError(response, '/event');
}
if (!response.body) {
return;
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split(/\n\n/);
buffer = parts.pop() ?? '';
for (const part of parts) {
const event = parseServerSentEvent(part);
if (event) {
yield event;
}
}
}
const finalEvent = parseServerSentEvent(buffer);
if (finalEvent) {
yield finalEvent;
}
}
private async request<T>(path: string, options: { method?: string; body?: unknown } = {}) {
const response = await this.fetchImpl(`${this.baseUrl}${path}`, {
method: options.method ?? 'GET',
headers: this.createHeaders(options.body !== undefined),
body: options.body === undefined ? undefined : JSON.stringify(options.body),
});
if (!response.ok) {
throw await this.createHttpError(response, path);
}
if (response.status === 204) {
return undefined as T;
}
const text = await response.text();
if (!text) {
return undefined as T;
}
return JSON.parse(text) as T;
}
private createHeaders(hasJsonBody = false) {
const headers: Record<string, string> = {};
if (hasJsonBody) {
headers['content-type'] = 'application/json';
}
if (this.authorizationHeader) {
headers.authorization = this.authorizationHeader;
}
return headers;
}
private async createHttpError(response: Response, path: string) {
const body = await response.text();
return new OpenCodeHttpError(`OpenCode request failed: ${response.status} ${path}`, response.status, body);
}
}
export const parseServerSentEvent = (raw: string): IOpenCodeEvent | undefined => {
const trimmed = raw.trim();
if (!trimmed) {
return undefined;
}
const dataLines: string[] = [];
let type = 'message';
let id: string | undefined;
let retry: number | undefined;
for (const line of trimmed.split(/\r?\n/)) {
if (line.startsWith(':')) {
continue;
}
const separator = line.indexOf(':');
const field = separator === -1 ? line : line.slice(0, separator);
const value = separator === -1 ? '' : line.slice(separator + 1).replace(/^ /, '');
if (field === 'event') {
type = value;
} else if (field === 'data') {
dataLines.push(value);
} else if (field === 'id') {
id = value;
} else if (field === 'retry') {
const retryNumber = Number(value);
if (Number.isFinite(retryNumber)) {
retry = retryNumber;
}
}
}
const dataText = dataLines.join('\n');
const data = parseJsonIfPossible(dataText);
if (type === 'message' && data && typeof data === 'object' && 'type' in data) {
type = String((data as { type: unknown }).type);
}
return { type, id, retry, data, raw };
};
const parseJsonIfPossible = (value: string) => {
if (!value) {
return undefined;
}
try {
return JSON.parse(value) as unknown;
} catch {
return value;
}
};
const stringifyQuery = (query: Record<string, string | number | boolean | undefined>) => {
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(query)) {
if (value !== undefined) {
result[key] = String(value);
}
}
return result;
};
+3
View File
@@ -0,0 +1,3 @@
import { Buffer } from 'node:buffer';
export { Buffer };
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "ts",
"outDir": "dist_ts",
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["ts/**/*.ts"]
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "@git.zone/ide-protocol",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist_ts/index.js",
"typings": "dist_ts/index.d.ts",
"scripts": {
"build": "tsc -p tsconfig.json"
},
"files": [
"ts/**/*",
"dist_ts/**/*"
]
}
+80
View File
@@ -0,0 +1,80 @@
export const gitZoneIdeProtocolVersion = 1;
export type TRemoteProcessStatus = 'starting' | 'running' | 'stopped' | 'failed';
export interface IIdeSshTarget {
id: string;
label?: string;
hostAlias: string;
hostName?: string;
user?: string;
port?: number;
workspacePath?: string;
}
export interface IRemoteServerManifest {
protocolVersion: number;
serverVersion: string;
platform: string;
arch: string;
artifactName: string;
sha256?: string;
createdAt: string;
}
export interface IRemoteServerPaths {
installRoot: string;
versionRoot: string;
currentLink: string;
logsDir: string;
manifestPath: string;
}
export interface IRemoteOpenCodeDescriptor {
baseUrl: string;
port: number;
username: string;
password: string;
status: TRemoteProcessStatus;
}
export interface IRemoteTheiaDescriptor {
baseUrl: string;
localPort: number;
remotePort: number;
status: TRemoteProcessStatus;
}
export interface IRemoteSessionDescriptor {
sessionId: string;
target: IIdeSshTarget;
workspacePath: string;
serverVersion: string;
createdAt: string;
theia: IRemoteTheiaDescriptor;
opencode?: IRemoteOpenCodeDescriptor;
}
export interface IRemoteProbeResult {
ok: boolean;
hostAlias: string;
platform?: string;
arch?: string;
homeDir?: string;
shell?: string;
nodeVersion?: string;
pnpmVersion?: string;
gitVersion?: string;
opencodeVersion?: string;
errors: string[];
}
export interface IPortForwardDescriptor {
id: string;
label?: string;
localHost: string;
localPort: number;
remoteHost: string;
remotePort: number;
status: TRemoteProcessStatus;
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "ts",
"outDir": "dist_ts",
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["ts/**/*.ts"]
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "@git.zone/ide-server-installer",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist_ts/index.js",
"typings": "dist_ts/index.d.ts",
"scripts": {
"build": "tsc -p tsconfig.json"
},
"dependencies": {
"@git.zone/ide-protocol": "workspace:*"
},
"files": [
"ts/**/*",
"dist_ts/**/*"
]
}
+141
View File
@@ -0,0 +1,141 @@
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<string, string>;
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, '');
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "ts",
"outDir": "dist_ts",
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["ts/**/*.ts"]
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "@git.zone/ide-ssh",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "dist_ts/index.js",
"typings": "dist_ts/index.d.ts",
"scripts": {
"build": "tsc -p tsconfig.json"
},
"dependencies": {
"@git.zone/ide-protocol": "workspace:*"
},
"files": [
"ts/**/*",
"dist_ts/**/*"
]
}
+397
View File
@@ -0,0 +1,397 @@
import type { IIdeSshTarget, IRemoteProbeResult } from '@git.zone/ide-protocol';
import * as plugins from './plugins.js';
export interface ISshHostConfig {
alias: string;
patterns: string[];
hostName?: string;
user?: string;
port?: number;
identityFiles: string[];
proxyJump?: string;
forwardAgent?: boolean;
raw: Record<string, string[]>;
}
export interface ISshRunOptions {
executable?: string;
timeoutMs?: number;
batchMode?: boolean;
cwd?: string;
env?: NodeJS.ProcessEnv;
}
export interface ISshRunResult {
exitCode: number;
stdout: string;
stderr: string;
}
export interface ISshTunnelOptions extends ISshRunOptions {
localHost?: string;
localPort: number;
remoteHost?: string;
remotePort: number;
}
export interface ISshTunnelHandle {
readonly target: IIdeSshTarget;
readonly localHost: string;
readonly localPort: number;
readonly remoteHost: string;
readonly remotePort: number;
readonly processId: number | undefined;
dispose(signal?: NodeJS.Signals): Promise<void>;
}
const directiveAliases: Record<string, keyof ISshHostConfig | 'identityFiles'> = {
hostname: 'hostName',
user: 'user',
port: 'port',
identityfile: 'identityFiles',
proxyjump: 'proxyJump',
forwardagent: 'forwardAgent',
};
export const defaultSshConfigPath = () => plugins.path.join(plugins.os.homedir(), '.ssh', 'config');
export const expandHome = (filePath: string) => {
if (filePath === '~') {
return plugins.os.homedir();
}
if (filePath.startsWith('~/')) {
return plugins.path.join(plugins.os.homedir(), filePath.slice(2));
}
return filePath;
};
export const parseSshConfig = (configText: string): ISshHostConfig[] => {
const hosts: ISshHostConfig[] = [];
let currentHosts: ISshHostConfig[] = [];
for (const rawLine of configText.split(/\r?\n/)) {
const line = stripSshComment(rawLine).trim();
if (!line) {
continue;
}
const tokens = tokenizeSshConfigLine(line);
if (tokens.length === 0) {
continue;
}
const key = tokens[0]!.toLowerCase();
const values = tokens.slice(1);
if (key === 'host') {
currentHosts = values.map((alias) => ({
alias,
patterns: values,
identityFiles: [],
raw: {},
}));
hosts.push(...currentHosts);
continue;
}
for (const host of currentHosts) {
host.raw[key] = [...(host.raw[key] ?? []), ...values];
applySshDirective(host, key, values);
}
}
return hosts;
};
export const readSshConfig = async (filePath = defaultSshConfigPath()) => {
try {
const configText = await plugins.fs.readFile(expandHome(filePath), 'utf8');
return parseSshConfig(configText);
} catch (error) {
const nodeError = error as NodeJS.ErrnoException;
if (nodeError.code === 'ENOENT') {
return [];
}
throw error;
}
};
export const listConnectableHosts = (hosts: ISshHostConfig[]) => {
return hosts.filter((host) => !isWildcardHostPattern(host.alias) && !host.alias.startsWith('!'));
};
export const buildSshDestination = (target: Pick<IIdeSshTarget, 'hostAlias' | 'user'>) => {
return target.user ? `${target.user}@${target.hostAlias}` : target.hostAlias;
};
export const buildSshArgs = (
target: IIdeSshTarget,
remoteCommand?: string,
options: ISshRunOptions = {},
) => {
const args = buildSshOptionArgs(target, options);
args.push(buildSshDestination(target));
if (remoteCommand) {
args.push(remoteCommand);
}
return args;
};
export const runSshCommand = async (
target: IIdeSshTarget,
remoteCommand: string,
options: ISshRunOptions = {},
): Promise<ISshRunResult> => {
const executable = options.executable ?? 'ssh';
const args = buildSshArgs(target, remoteCommand, options);
return new Promise<ISshRunResult>((resolve, reject) => {
const child = plugins.childProcess.spawn(executable, args, {
cwd: options.cwd,
env: options.env ?? process.env,
shell: false,
stdio: ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
const stdout: Buffer[] = [];
const stderr: Buffer[] = [];
let finished = false;
const timeout = options.timeoutMs
? setTimeout(() => {
if (!finished) {
child.kill('SIGTERM');
}
}, options.timeoutMs)
: undefined;
child.stdout.on('data', (chunk: Buffer) => stdout.push(chunk));
child.stderr.on('data', (chunk: Buffer) => stderr.push(chunk));
child.on('error', (error) => {
finished = true;
if (timeout) {
clearTimeout(timeout);
}
reject(error);
});
child.on('close', (exitCode) => {
finished = true;
if (timeout) {
clearTimeout(timeout);
}
resolve({
exitCode: exitCode ?? 1,
stdout: Buffer.concat(stdout).toString('utf8'),
stderr: Buffer.concat(stderr).toString('utf8'),
});
});
});
};
export const findFreePort = async (host = '127.0.0.1') => {
return new Promise<number>((resolve, reject) => {
const server = plugins.net.createServer();
server.unref();
server.on('error', reject);
server.listen(0, host, () => {
const address = server.address();
server.close(() => {
if (typeof address === 'object' && address) {
resolve(address.port);
} else {
reject(new Error('Unable to allocate a free local port'));
}
});
});
});
};
export const startSshTunnel = (
target: IIdeSshTarget,
options: ISshTunnelOptions,
): ISshTunnelHandle => {
const executable = options.executable ?? 'ssh';
const localHost = options.localHost ?? '127.0.0.1';
const remoteHost = options.remoteHost ?? '127.0.0.1';
const forward = `${localHost}:${options.localPort}:${remoteHost}:${options.remotePort}`;
const args = [
...buildSshOptionArgs(target, options),
'-N',
'-L',
forward,
buildSshDestination(target),
];
const child = plugins.childProcess.spawn(executable, args, {
cwd: options.cwd,
env: options.env ?? process.env,
shell: false,
stdio: ['ignore', 'ignore', 'pipe'],
windowsHide: true,
});
return {
target,
localHost,
localPort: options.localPort,
remoteHost,
remotePort: options.remotePort,
processId: child.pid,
dispose: async (signal: NodeJS.Signals = 'SIGTERM') => {
if (child.exitCode !== null || child.killed) {
return;
}
await new Promise<void>((resolve) => {
const timer = setTimeout(() => {
if (!child.killed) {
child.kill('SIGKILL');
}
resolve();
}, 5000);
child.once('close', () => {
clearTimeout(timer);
resolve();
});
child.kill(signal);
});
},
};
};
export const probeRemoteHost = async (
target: IIdeSshTarget,
options: ISshRunOptions = {},
): Promise<IRemoteProbeResult> => {
const command = [
`printf 'platform=%s\\n' "$(uname -s 2>/dev/null || printf unknown)"`,
`printf 'arch=%s\\n' "$(uname -m 2>/dev/null || printf unknown)"`,
`printf 'homeDir=%s\\n' "$HOME"`,
`printf 'shell=%s\\n' "$SHELL"`,
`printf 'nodeVersion=%s\\n' "$(node --version 2>/dev/null || true)"`,
`printf 'pnpmVersion=%s\\n' "$(pnpm --version 2>/dev/null || true)"`,
`printf 'gitVersion=%s\\n' "$(git --version 2>/dev/null || true)"`,
`printf 'opencodeVersion=%s\\n' "$(opencode --version 2>/dev/null || true)"`,
].join('; ');
const result = await runSshCommand(target, command, options);
const fields = parseKeyValueLines(result.stdout);
const errors: string[] = [];
if (result.exitCode !== 0) {
errors.push(result.stderr || `ssh exited with code ${result.exitCode}`);
}
return {
ok: result.exitCode === 0,
hostAlias: target.hostAlias,
platform: fields.platform,
arch: fields.arch,
homeDir: fields.homeDir,
shell: fields.shell,
nodeVersion: fields.nodeVersion,
pnpmVersion: fields.pnpmVersion,
gitVersion: fields.gitVersion,
opencodeVersion: fields.opencodeVersion,
errors,
};
};
const applySshDirective = (host: ISshHostConfig, key: string, values: string[]) => {
const targetKey = directiveAliases[key];
if (!targetKey || values.length === 0) {
return;
}
if (targetKey === 'identityFiles') {
host.identityFiles.push(...values.map(expandHome));
return;
}
if (targetKey === 'port') {
const port = Number(values[0]);
if (Number.isInteger(port) && port > 0) {
host.port = port;
}
return;
}
if (targetKey === 'forwardAgent') {
host.forwardAgent = /^(yes|true)$/i.test(values[0]!);
return;
}
if (targetKey === 'hostName') {
host.hostName = values.join(' ');
} else if (targetKey === 'user') {
host.user = values.join(' ');
} else if (targetKey === 'proxyJump') {
host.proxyJump = values.join(' ');
}
};
const buildSshOptionArgs = (target: IIdeSshTarget, options: ISshRunOptions = {}) => {
const args: string[] = [];
if (options.batchMode !== false) {
args.push('-o', 'BatchMode=yes');
}
args.push('-o', 'ServerAliveInterval=30');
args.push('-o', 'ServerAliveCountMax=3');
if (target.port) {
args.push('-p', `${target.port}`);
}
return args;
};
const stripSshComment = (line: string) => {
let quote: string | undefined;
for (let index = 0; index < line.length; index++) {
const character = line[index];
if ((character === '"' || character === "'") && line[index - 1] !== '\\') {
quote = quote === character ? undefined : character;
continue;
}
if (character === '#' && !quote) {
return line.slice(0, index);
}
}
return line;
};
const tokenizeSshConfigLine = (line: string) => {
const tokens: string[] = [];
let current = '';
let quote: string | undefined;
for (let index = 0; index < line.length; index++) {
const character = line[index]!;
if ((character === '"' || character === "'") && line[index - 1] !== '\\') {
quote = quote === character ? undefined : character;
continue;
}
if (/\s/.test(character) && !quote) {
if (current) {
tokens.push(current);
current = '';
}
continue;
}
current += character;
}
if (current) {
tokens.push(current);
}
return tokens;
};
const isWildcardHostPattern = (alias: string) => /[*?!]/.test(alias);
const parseKeyValueLines = (text: string) => {
const fields: Record<string, string> = {};
for (const line of text.split(/\r?\n/)) {
const separator = line.indexOf('=');
if (separator === -1) {
continue;
}
fields[line.slice(0, separator)] = line.slice(separator + 1);
}
return fields;
};
+7
View File
@@ -0,0 +1,7 @@
import * as childProcess from 'node:child_process';
import * as fs from 'node:fs/promises';
import * as net from 'node:net';
import * as os from 'node:os';
import * as path from 'node:path';
export { childProcess, fs, net, os, path };
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "ts",
"outDir": "dist_ts",
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["ts/**/*.ts"]
}