Initialize remote IDE scaffold
This commit is contained in:
@@ -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/**/*"
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
import { Buffer } from 'node:buffer';
|
||||
|
||||
export { Buffer };
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "ts",
|
||||
"outDir": "dist_ts",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["ts/**/*.ts"]
|
||||
}
|
||||
@@ -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/**/*"
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "ts",
|
||||
"outDir": "dist_ts",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["ts/**/*.ts"]
|
||||
}
|
||||
@@ -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/**/*"
|
||||
]
|
||||
}
|
||||
@@ -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, '');
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "ts",
|
||||
"outDir": "dist_ts",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["ts/**/*.ts"]
|
||||
}
|
||||
@@ -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/**/*"
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "ts",
|
||||
"outDir": "dist_ts",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["ts/**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user